| 1 |
// The Releases view (spec §8.2): each repository's releases with their |
| 2 |
// assets, and drafting a release from a tag with notes from history. |
| 3 |
|
| 4 |
import * as vscode from "vscode"; |
| 5 |
import type * as api from "./hubapi"; |
| 6 |
import { HubAuth } from "./auth"; |
| 7 |
import { looksPrerelease, releaseNotes, trustMarkdown } from "./hub"; |
| 8 |
import type { HubRepo } from "./proposals"; |
| 9 |
|
| 10 |
type Node = |
| 11 |
| { kind: "repo"; repo: HubRepo } |
| 12 |
| { kind: "release"; repo: HubRepo; r: api.Release } |
| 13 |
| { kind: "asset"; repo: HubRepo; r: api.Release; name: string; url: string; size?: number; downloads?: number }; |
| 14 |
|
| 15 |
export type ReleaseNode = Extract<Node, { kind: "release" }>; |
| 16 |
export type AssetNode = Extract<Node, { kind: "asset" }>; |
| 17 |
|
| 18 |
export class ReleasesProvider implements vscode.TreeDataProvider<Node> { |
| 19 |
private readonly emitter = new vscode.EventEmitter<void>(); |
| 20 |
readonly onDidChangeTreeData = this.emitter.event; |
| 21 |
|
| 22 |
constructor( |
| 23 |
private readonly repos: () => Promise<HubRepo[]>, |
| 24 |
private readonly auth: HubAuth, |
| 25 |
) {} |
| 26 |
|
| 27 |
refresh(): void { |
| 28 |
this.emitter.fire(); |
| 29 |
} |
| 30 |
|
| 31 |
getTreeItem(node: Node): vscode.TreeItem { |
| 32 |
switch (node.kind) { |
| 33 |
case "repo": { |
| 34 |
const item = new vscode.TreeItem(`${node.repo.owner}/${node.repo.name}`, vscode.TreeItemCollapsibleState.Expanded); |
| 35 |
item.iconPath = new vscode.ThemeIcon("repo"); |
| 36 |
item.contextValue = "releaseRepo"; |
| 37 |
return item; |
| 38 |
} |
| 39 |
case "release": { |
| 40 |
const r = node.r; |
| 41 |
const item = new vscode.TreeItem(r.title || r.tag, vscode.TreeItemCollapsibleState.Collapsed); |
| 42 |
item.description = [r.tag, r.latest ? "latest" : "", r.prerelease ? "pre-release" : "", new Date(r.created_ms).toLocaleDateString()].filter(Boolean).join(" · "); |
| 43 |
item.iconPath = new vscode.ThemeIcon(r.prerelease ? "beaker" : "tag"); |
| 44 |
item.tooltip = new vscode.MarkdownString(`**${r.title || r.tag}** · ${r.tag} by ${r.author}\n\n${r.notes.slice(0, 600)}`); |
| 45 |
item.contextValue = "release"; |
| 46 |
item.command = { title: "Open", command: "vscode.open", arguments: [vscode.Uri.parse(r.url)] }; |
| 47 |
return item; |
| 48 |
} |
| 49 |
case "asset": { |
| 50 |
const item = new vscode.TreeItem(node.name, vscode.TreeItemCollapsibleState.None); |
| 51 |
item.description = node.size !== undefined ? `${formatSize(node.size)} · ${node.downloads ?? 0} downloads` : "source archive"; |
| 52 |
item.iconPath = new vscode.ThemeIcon(node.size !== undefined ? "package" : "file-zip"); |
| 53 |
item.contextValue = "releaseAsset"; |
| 54 |
item.command = { title: "Download", command: "vscode.open", arguments: [vscode.Uri.parse(node.url)] }; |
| 55 |
return item; |
| 56 |
} |
| 57 |
} |
| 58 |
} |
| 59 |
|
| 60 |
async getChildren(node?: Node): Promise<Node[]> { |
| 61 |
if (!node) { |
| 62 |
return (await this.repos()).map((repo) => ({ kind: "repo", repo })); |
| 63 |
} |
| 64 |
if (node.kind === "repo") { |
| 65 |
try { |
| 66 |
const list = await this.auth.clientFor(node.repo.base).releases(node.repo.owner, node.repo.name); |
| 67 |
return list.map((r) => ({ kind: "release", repo: node.repo, r })); |
| 68 |
} catch (e) { |
| 69 |
void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`); |
| 70 |
return []; |
| 71 |
} |
| 72 |
} |
| 73 |
if (node.kind === "release") { |
| 74 |
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 })); |
| 75 |
const archives: Node[] = node.r.archives.map((url) => ({ kind: "asset", repo: node.repo, r: node.r, name: url.split("/").pop() ?? url, url })); |
| 76 |
return [...assets, ...archives]; |
| 77 |
} |
| 78 |
return []; |
| 79 |
} |
| 80 |
} |
| 81 |
|
| 82 |
function formatSize(n: number): string { |
| 83 |
return n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`; |
| 84 |
} |
| 85 |
|
| 86 |
/** Draft a release: a tag without one, a title, notes from the changes since the last release. */ |
| 87 |
export async function draftRelease(auth: HubAuth, repo: HubRepo): Promise<api.Release | undefined> { |
| 88 |
const client = auth.clientFor(repo.base); |
| 89 |
const [tags, releases] = await Promise.all([client.tags(repo.owner, repo.name), client.releases(repo.owner, repo.name)]); |
| 90 |
const released = new Set(releases.map((r) => r.tag)); |
| 91 |
const open = tags.filter((t) => !released.has(t.name)); |
| 92 |
if (!open.length) { |
| 93 |
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.`); |
| 94 |
return undefined; |
| 95 |
} |
| 96 |
const pick = await vscode.window.showQuickPick( |
| 97 |
open.map((t) => ({ label: t.name, description: t.message.split("\n")[0], detail: t.signer ? "signed" : undefined, t })), |
| 98 |
{ placeHolder: "Tag to release" }, |
| 99 |
); |
| 100 |
if (!pick) { |
| 101 |
return undefined; |
| 102 |
} |
| 103 |
const title = await vscode.window.showInputBox({ prompt: "Release title", value: pick.t.message.split("\n")[0] || pick.label }); |
| 104 |
if (title === undefined) { |
| 105 |
return undefined; |
| 106 |
} |
| 107 |
const previous = releases.filter((r) => !r.prerelease).sort((a, b) => b.created_ms - a.created_ms)[0]; |
| 108 |
const current = await client.history(repo.owner, repo.name, pick.label, 500); |
| 109 |
const before = previous ? await client.history(repo.owner, repo.name, previous.tag, 500) : { changes: [] as api.ChangeSummary[] }; |
| 110 |
const notes = releaseNotes(current.changes, before.changes, previous?.tag); |
| 111 |
const pre = await vscode.window.showQuickPick( |
| 112 |
[ |
| 113 |
{ label: "Release", pre: false }, |
| 114 |
{ label: "Pre-release", pre: true }, |
| 115 |
].sort((a) => (a.pre === looksPrerelease(pick.label) ? -1 : 1)), |
| 116 |
{ placeHolder: "Kind" }, |
| 117 |
); |
| 118 |
if (!pre) { |
| 119 |
return undefined; |
| 120 |
} |
| 121 |
return client.createRelease(repo.owner, repo.name, { tag: pick.label, title: title || pick.label, notes, prerelease: pre.pre }); |
| 122 |
} |
| 123 |
|
| 124 |
/** A release's trust report (#79) as a Markdown preview: the tag's signature, who signed the changes, which build made each asset, and the audit. */ |
| 125 |
export async function showTrust(auth: HubAuth, node: ReleaseNode): Promise<void> { |
| 126 |
const t = await auth.clientFor(node.repo.base).releaseTrust(node.repo.owner, node.repo.name, node.r.tag); |
| 127 |
const doc = await vscode.workspace.openTextDocument({ content: trustMarkdown(t), language: "markdown" }); |
| 128 |
await vscode.commands.executeCommand("markdown.showPreview", doc.uri); |
| 129 |
} |
| 130 |
|
| 131 |
/** Save a release's asset to a file, with the hub session's token when it needs one. */ |
| 132 |
export async function saveAsset(auth: HubAuth, node: AssetNode): Promise<void> { |
| 133 |
const folder = vscode.workspace.workspaceFolders?.[0]?.uri; |
| 134 |
const target = await vscode.window.showSaveDialog({ defaultUri: folder ? vscode.Uri.joinPath(folder, node.name) : undefined, saveLabel: "Save Asset" }); |
| 135 |
if (!target) { |
| 136 |
return; |
| 137 |
} |
| 138 |
const bytes = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `OxygenHub: downloading ${node.name}…` }, () => |
| 139 |
auth.clientFor(node.repo.base).download(node.url), |
| 140 |
); |
| 141 |
await vscode.workspace.fs.writeFile(target, bytes); |
| 142 |
vscode.window.setStatusBarMessage(`$(check) Saved ${node.name}`, 4000); |
| 143 |
} |