// The Releases view (spec §8.2): each repository's releases with their // assets, and drafting a release from a tag with notes from history. import * as vscode from "vscode"; import type * as api from "./hubapi"; import { HubAuth } from "./auth"; import { looksPrerelease, releaseNotes } from "./hub"; import type { HubRepo } from "./proposals"; type Node = | { kind: "repo"; repo: HubRepo } | { kind: "release"; repo: HubRepo; r: api.Release } | { kind: "asset"; repo: HubRepo; r: api.Release; name: string; url: string; size?: number; downloads?: number }; export type ReleaseNode = Extract; export class ReleasesProvider implements vscode.TreeDataProvider { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.emitter.event; constructor( private readonly repos: () => Promise, private readonly auth: HubAuth, ) {} refresh(): void { this.emitter.fire(); } getTreeItem(node: Node): vscode.TreeItem { switch (node.kind) { case "repo": { const item = new vscode.TreeItem(`${node.repo.owner}/${node.repo.name}`, vscode.TreeItemCollapsibleState.Expanded); item.iconPath = new vscode.ThemeIcon("repo"); item.contextValue = "releaseRepo"; return item; } case "release": { const r = node.r; const item = new vscode.TreeItem(r.title || r.tag, vscode.TreeItemCollapsibleState.Collapsed); item.description = [r.tag, r.latest ? "latest" : "", r.prerelease ? "pre-release" : "", new Date(r.created_ms).toLocaleDateString()].filter(Boolean).join(" · "); item.iconPath = new vscode.ThemeIcon(r.prerelease ? "beaker" : "tag"); item.tooltip = new vscode.MarkdownString(`**${r.title || r.tag}** · ${r.tag} by ${r.author}\n\n${r.notes.slice(0, 600)}`); item.contextValue = "release"; item.command = { title: "Open", command: "vscode.open", arguments: [vscode.Uri.parse(r.url)] }; return item; } case "asset": { const item = new vscode.TreeItem(node.name, vscode.TreeItemCollapsibleState.None); item.description = node.size !== undefined ? `${formatSize(node.size)} · ${node.downloads ?? 0} downloads` : "source archive"; item.iconPath = new vscode.ThemeIcon(node.size !== undefined ? "package" : "file-zip"); item.command = { title: "Download", command: "vscode.open", arguments: [vscode.Uri.parse(node.url)] }; return item; } } } async getChildren(node?: Node): Promise { if (!node) { return (await this.repos()).map((repo) => ({ kind: "repo", repo })); } if (node.kind === "repo") { try { const list = await this.auth.clientFor(node.repo.base).releases(node.repo.owner, node.repo.name); return list.map((r) => ({ kind: "release", repo: node.repo, r })); } catch (e) { void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`); return []; } } if (node.kind === "release") { const assets: Node[] = node.r.assets.map((a) => ({ kind: "asset", repo: node.repo, r: node.r, name: a.name, url: a.url, size: a.size, downloads: a.downloads })); const archives: Node[] = node.r.archives.map((url) => ({ kind: "asset", repo: node.repo, r: node.r, name: url.split("/").pop() ?? url, url })); return [...assets, ...archives]; } return []; } } function formatSize(n: number): string { return n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`; } /** Draft a release: a tag without one, a title, notes from the changes since the last release. */ export async function draftRelease(auth: HubAuth, repo: HubRepo): Promise { const client = auth.clientFor(repo.base); const [tags, releases] = await Promise.all([client.tags(repo.owner, repo.name), client.releases(repo.owner, repo.name)]); const released = new Set(releases.map((r) => r.tag)); const open = tags.filter((t) => !released.has(t.name)); if (!open.length) { void vscode.window.showInformationMessage(`OxygenHub: every tag of ${repo.owner}/${repo.name} has a release. Tag a state with \`oxy tag create\` and push it first.`); return undefined; } const pick = await vscode.window.showQuickPick( open.map((t) => ({ label: t.name, description: t.message.split("\n")[0], detail: t.signer ? "signed" : undefined, t })), { placeHolder: "Tag to release" }, ); if (!pick) { return undefined; } const title = await vscode.window.showInputBox({ prompt: "Release title", value: pick.t.message.split("\n")[0] || pick.label }); if (title === undefined) { return undefined; } const previous = releases.filter((r) => !r.prerelease).sort((a, b) => b.created_ms - a.created_ms)[0]; const current = await client.history(repo.owner, repo.name, pick.label, 500); const before = previous ? await client.history(repo.owner, repo.name, previous.tag, 500) : { changes: [] as api.ChangeSummary[] }; const notes = releaseNotes(current.changes, before.changes, previous?.tag); const pre = await vscode.window.showQuickPick( [ { label: "Release", pre: false }, { label: "Pre-release", pre: true }, ].sort((a) => (a.pre === looksPrerelease(pick.label) ? -1 : 1)), { placeHolder: "Kind" }, ); if (!pre) { return undefined; } return client.createRelease(repo.owner, repo.name, { tag: pick.label, title: title || pick.label, notes, prerelease: pre.pre }); }