OXYGENHUB
◆ main oxygenhub-vscode / src / releases.ts
6.8 KiB · last changed OxygenHub 0.13.0: Drops in the editor with Share as a Drop (closes #7); Flow approvals, expedite, reports, timings and test results (closes #8); ready/draft, landable, trust reports, asset downloads and new repositories (closes #9) 6 days ago source blame history raw
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
}