OXYGENHUB

Releases view and Draft Release from a tag with notes from history (0.6.0) 1cc0ff44e5ca

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 10:44 UTC
Signature
✔ signed by Andy McCall
Hash
1cc0ff44e5ca78b9abfb50f6c09a66f35f3575423f9bc5a3de2d0d2e139fb712
Depends on
486c15b00314 95f69b4a3ac2 b2dd082c57e4 f0b06deac4b9 f902d5b8bfeb
Depended on by
991516a107fa d21a85954a34 263de209e5ba 5888949525ec e04950748142 092b8bf9f2ac
In bookmarks
main
In tags
v0.10.0 v0.10.1 v0.10.2 v0.11.0 v0.11.1 v0.11.2 v0.13.0
Edits
22 atoms, 1 tree operation, 8 files

Browse the tree at this change

added src/releases.ts +120 −0
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 } 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 +
17 +
export class ReleasesProvider implements vscode.TreeDataProvider<Node> {
18 +
  private readonly emitter = new vscode.EventEmitter<void>();
19 +
  readonly onDidChangeTreeData = this.emitter.event;
20 +
21 +
  constructor(
22 +
    private readonly repos: () => Promise<HubRepo[]>,
23 +
    private readonly auth: HubAuth,
24 +
  ) {}
25 +
26 +
  refresh(): void {
27 +
    this.emitter.fire();
28 +
  }
29 +
30 +
  getTreeItem(node: Node): vscode.TreeItem {
31 +
    switch (node.kind) {
32 +
      case "repo": {
33 +
        const item = new vscode.TreeItem(`${node.repo.owner}/${node.repo.name}`, vscode.TreeItemCollapsibleState.Expanded);
34 +
        item.iconPath = new vscode.ThemeIcon("repo");
35 +
        item.contextValue = "releaseRepo";
36 +
        return item;
37 +
      }
38 +
      case "release": {
39 +
        const r = node.r;
40 +
        const item = new vscode.TreeItem(r.title || r.tag, vscode.TreeItemCollapsibleState.Collapsed);
41 +
        item.description = [r.tag, r.latest ? "latest" : "", r.prerelease ? "pre-release" : "", new Date(r.created_ms).toLocaleDateString()].filter(Boolean).join(" · ");
42 +
        item.iconPath = new vscode.ThemeIcon(r.prerelease ? "beaker" : "tag");
43 +
        item.tooltip = new vscode.MarkdownString(`**${r.title || r.tag}** · ${r.tag} by ${r.author}\n\n${r.notes.slice(0, 600)}`);
44 +
        item.contextValue = "release";
45 +
        item.command = { title: "Open", command: "vscode.open", arguments: [vscode.Uri.parse(r.url)] };
46 +
        return item;
47 +
      }
48 +
      case "asset": {
49 +
        const item = new vscode.TreeItem(node.name, vscode.TreeItemCollapsibleState.None);
50 +
        item.description = node.size !== undefined ? `${formatSize(node.size)} · ${node.downloads ?? 0} downloads` : "source archive";
51 +
        item.iconPath = new vscode.ThemeIcon(node.size !== undefined ? "package" : "file-zip");
52 +
        item.command = { title: "Download", command: "vscode.open", arguments: [vscode.Uri.parse(node.url)] };
53 +
        return item;
54 +
      }
55 +
    }
56 +
  }
57 +
58 +
  async getChildren(node?: Node): Promise<Node[]> {
59 +
    if (!node) {
60 +
      return (await this.repos()).map((repo) => ({ kind: "repo", repo }));
61 +
    }
62 +
    if (node.kind === "repo") {
63 +
      try {
64 +
        const list = await this.auth.clientFor(node.repo.base).releases(node.repo.owner, node.repo.name);
65 +
        return list.map((r) => ({ kind: "release", repo: node.repo, r }));
66 +
      } catch (e) {
67 +
        void vscode.window.showWarningMessage(`Oxygenhub: ${(e as Error).message}`);
68 +
        return [];
69 +
      }
70 +
    }
71 +
    if (node.kind === "release") {
72 +
      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 }));
73 +
      const archives: Node[] = node.r.archives.map((url) => ({ kind: "asset", repo: node.repo, r: node.r, name: url.split("/").pop() ?? url, url }));
74 +
      return [...assets, ...archives];
75 +
    }
76 +
    return [];
77 +
  }
78 +
}
79 +
80 +
function formatSize(n: number): string {
81 +
  return n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`;
82 +
}
83 +
84 +
/** Draft a release: a tag without one, a title, notes from the changes since the last release. */
85 +
export async function draftRelease(auth: HubAuth, repo: HubRepo): Promise<api.Release | undefined> {
86 +
  const client = auth.clientFor(repo.base);
87 +
  const [tags, releases] = await Promise.all([client.tags(repo.owner, repo.name), client.releases(repo.owner, repo.name)]);
88 +
  const released = new Set(releases.map((r) => r.tag));
89 +
  const open = tags.filter((t) => !released.has(t.name));
90 +
  if (!open.length) {
91 +
    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.`);
92 +
    return undefined;
93 +
  }
94 +
  const pick = await vscode.window.showQuickPick(
95 +
    open.map((t) => ({ label: t.name, description: t.message.split("\n")[0], detail: t.signer ? "signed" : undefined, t })),
96 +
    { placeHolder: "Tag to release" },
97 +
  );
98 +
  if (!pick) {
99 +
    return undefined;
100 +
  }
101 +
  const title = await vscode.window.showInputBox({ prompt: "Release title", value: pick.t.message.split("\n")[0] || pick.label });
102 +
  if (title === undefined) {
103 +
    return undefined;
104 +
  }
105 +
  const previous = releases.filter((r) => !r.prerelease).sort((a, b) => b.created_ms - a.created_ms)[0];
106 +
  const current = await client.history(repo.owner, repo.name, pick.label, 500);
107 +
  const before = previous ? await client.history(repo.owner, repo.name, previous.tag, 500) : { changes: [] as api.ChangeSummary[] };
108 +
  const notes = releaseNotes(current.changes, before.changes, previous?.tag);
109 +
  const pre = await vscode.window.showQuickPick(
110 +
    [
111 +
      { label: "Release", pre: false },
112 +
      { label: "Pre-release", pre: true },
113 +
    ].sort((a) => (a.pre === looksPrerelease(pick.label) ? -1 : 1)),
114 +
    { placeHolder: "Kind" },
115 +
  );
116 +
  if (!pre) {
117 +
    return undefined;
118 +
  }
119 +
  return client.createRelease(repo.owner, repo.name, { tag: pick.label, title: title || pick.label, notes, prerelease: pre.pre });
120 +
}
modified CHANGELOG.md +5 −0
1 1
# Changelog
2 2
3 +
## 0.6.0
4 +
5 +
- A **Releases** view: each repository's releases with their assets and source archives, to open or download.
6 +
- **Draft Release…**: pick a tag without a release, confirm the title, and the notes are written from the changes since the last release.
7 +
3 8
## 0.5.0
4 9
5 10
- **New Issue from Selection** in the editor's context menu: the selected lines (or the current one) quoted in a new issue with a permalink to them on the hub. A TODO or FIXME line suggests the title, and the issue's number can be written back into it as `TODO(#12)`.
…
39 unchanged lines
modified README.md +1 −0
…
12 unchanged lines
13 13
- **Diffs in the editor**: expand a proposal to its files and open each in VS Code's diff editor (target against projected state), or View Changes for all of them at once, without checking out.
14 14
- **Review threads** in those diffs and on the checked-out proposal's files, anchored to atoms so they follow lines through rebases.
15 15
- **Issues** view: open, create, comment, close, assign.
16 +
- **Releases** view: releases with their assets; draft a release from a tag with notes written from the changes since the last one.
16 17
- **Open on Remote** in the Oxygen extension works for hubs once this extension is installed.
17 18
- **Status bar**: the checks of the proposal whose source is your current bookmark, live.
18 19
…
1 unchanged lines
modified docs/spec.md +6 −0
…
82 unchanged lines
83 83
84 84
**New Issue from Selection** (editor context menu) opens an issue quoting the selected lines, or the current line when nothing is selected, under a permalink to them on the hub: to the bookmark's head when it has exactly one and the file is saved, otherwise to the bookmark, with a note that the lines may move. A `TODO`, `FIXME`, `XXX` or `HACK` line suggests the title, and after the issue is opened the extension offers to write its number into the line (`TODO(#12): …`). The new issue opens in the editor.
85 85
86 +
### 8.2 Releases
87 +
88 +
The **Releases** view lists each repository's releases, newest first (`GET /repos/{o}/{n}/releases`): title, tag, `latest` or `pre-release`, date; the tooltip shows the start of the notes. Expanding a release lists its uploaded assets (size, downloads) and the hub's source archives; picking one downloads it through the browser, picking the release opens it on the hub.
89 +
90 +
**Draft Release…** (the view's title bar, or inline on a repository) releases a tag that has none yet: pick the tag (`GET /tags`, minus those already released), confirm a title (the tag's message by default), and choose release or pre-release (a tag such as `v2.0-rc1` or `1.0.0-beta.2` suggests pre-release). The notes are generated: the changes in the tag's history that the newest full release's tag does not have, newest first, one line each with the message's subject and a short hash. The release is created with `POST /releases`; assets are uploaded on the hub or with `oxy`. Editing notes after creation happens on the hub.
91 +
86 92
## 9. Checks and status bar
87 93
88 94
A status bar item shows, for the current repository's current bookmark, the open proposal whose source it is and the state of its checks (✔ n passed, ✘ failing, … running). It subscribes to the hub's proposal event stream and refreshes on `check` events; clicking opens the proposal.
…
22 unchanged lines
modified package.json +22 −2
…
1 unchanged lines
2 2
  "name": "oxygenhub-vscode",
3 3
  "displayName": "Oxygenhub",
4 4
  "description": "Proposals, reviews, issues and checks from an Oxygenhub, in the editor. Builds on the Oxygen extension.",
5 -
  "version": "0.5.0",
5 +
  "version": "0.6.0",
6 6
  "publisher": "andymccall",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygenhub.png",
…
73 unchanged lines
82 82
        {
83 83
          "id": "oxygenhub.issues",
84 84
          "name": "Issues"
85 +
        },
86 +
        {
87 +
          "id": "oxygenhub.releases",
88 +
          "name": "Releases"
85 89
        }
86 90
      ]
87 91
    },
…
85 unchanged lines
173 177
        "title": "New Issue\u2026",
174 178
        "category": "Oxygenhub",
175 179
        "icon": "$(add)"
180 +
      },
181 +
      {
182 +
        "command": "oxygenhub.draftRelease",
183 +
        "title": "Draft Release\u2026",
184 +
        "category": "Oxygenhub",
185 +
        "icon": "$(tag)"
176 186
      },
177 187
      {
178 188
        "command": "oxygenhub.issueFromSelection",
…
45 unchanged lines
224 234
      "view/title": [
225 235
        {
226 236
          "command": "oxygenhub.refresh",
227 -
          "when": "view == oxygenhub.proposals || view == oxygenhub.issues",
237 +
          "when": "view == oxygenhub.proposals || view == oxygenhub.issues || view == oxygenhub.releases",
228 238
          "group": "navigation"
229 239
        },
230 240
        {
…
4 unchanged lines
235 245
        {
236 246
          "command": "oxygenhub.newIssue",
237 247
          "when": "view == oxygenhub.issues",
248 +
          "group": "navigation"
249 +
        },
250 +
        {
251 +
          "command": "oxygenhub.draftRelease",
252 +
          "when": "view == oxygenhub.releases",
238 253
          "group": "navigation"
239 254
        }
240 255
      ],
…
57 unchanged lines
298 313
          "command": "oxygenhub.reopenIssue",
299 314
          "when": "view == oxygenhub.issues && viewItem == issue",
300 315
          "group": "actions@3"
316 +
        },
317 +
        {
318 +
          "command": "oxygenhub.draftRelease",
319 +
          "when": "view == oxygenhub.releases && viewItem == releaseRepo",
320 +
          "group": "inline"
301 321
        }
302 322
      ],
303 323
      "comments/commentThread/context": [
…
51 unchanged lines
modified src/extension.ts +24 −0
…
6 unchanged lines
7 7
import { ProposalContentProvider, SCHEME, openAllChanges, openFileDiff, refOf } from "./diff";
8 8
import { hubUrl } from "./hub";
9 9
import { InboxBar } from "./inbox";
10 +
import { draftRelease, ReleasesProvider } from "./releases";
10 11
import { ReferenceCompletions, ReferenceHovers, ReferenceIndex, SELECTOR } from "./references";
11 12
import { annotateTodo, issueBody, todoTitle } from "./refs";
12 13
import { IssueNode, IssuesProvider, showIssue } from "./issues";
…
11 unchanged lines
24 25
let content: ProposalContentProvider;
25 26
let inbox: InboxBar;
26 27
let references: ReferenceIndex;
28 +
let releases: ReleasesProvider;
27 29
28 30
export async function activate(context: vscode.ExtensionContext): Promise<void> {
29 31
  auth = new HubAuth(context.secrets);
…
28 unchanged lines
58 60
    return true;
59 61
  });
60 62
  context.subscriptions.push(inbox);
63 +
  releases = new ReleasesProvider(() => proposals.repos(), auth);
64 +
  context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygenhub.releases", releases));
61 65
  references = new ReferenceIndex(auth, () => proposals.repos());
62 66
  context.subscriptions.push(
63 67
    vscode.languages.registerCompletionItemProvider(SELECTOR, new ReferenceCompletions(references), "#", "@"),
…
19 unchanged lines
83 87
    }),
84 88
    vscode.commands.registerCommand("oxygenhub.refresh", () => refreshAll()),
85 89
    vscode.commands.registerCommand("oxygenhub.inbox", () => inbox.show()),
90 +
    vscode.commands.registerCommand("oxygenhub.draftRelease", async (node?: { repo?: HubRepo }) => {
91 +
      const repos = await proposals.repos();
92 +
      const repo = node?.repo ?? (repos.length === 1 ? repos[0] : (await vscode.window.showQuickPick(repos.map((r) => ({ label: `${r.owner}/${r.name}`, r })), { placeHolder: "Repository" }))?.r);
93 +
      if (!repo) {
94 +
        return;
95 +
      }
96 +
      try {
97 +
        const r = await draftRelease(auth, repo);
98 +
        if (r) {
99 +
          releases.refresh();
100 +
          const open = await vscode.window.showInformationMessage(`Oxygenhub: released ${r.tag}.`, "Open on Hub");
101 +
          if (open) {
102 +
            void vscode.env.openExternal(vscode.Uri.parse(r.url));
103 +
          }
104 +
        }
105 +
      } catch (e) {
106 +
        void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`);
107 +
      }
108 +
    }),
86 109
    vscode.commands.registerCommand("oxygenhub.openProposal", async (node?: ProposalNode) => {
87 110
      const n = node ?? (await pickProposal());
88 111
      if (!n) {
…
69 unchanged lines
158 181
  references.clear();
159 182
  refreshOpenDiffs();
160 183
  issues.refresh();
184 +
  releases.refresh();
161 185
  void bar.refresh();
162 186
  void inbox.refresh();
163 187
}
…
234 unchanged lines
modified src/hub.ts +31 −0
…
173 unchanged lines
174 174
    return this.request("GET", `/repos/${owner}/${name}/bookmarks`);
175 175
  }
176 176
177 +
  releases(owner: string, name: string): Promise<api.Release[]> {
178 +
    return this.request("GET", `/repos/${owner}/${name}/releases`);
179 +
  }
180 +
181 +
  createRelease(owner: string, name: string, r: api.NewRelease): Promise<api.Release> {
182 +
    return this.request("POST", `/repos/${owner}/${name}/releases`, r);
183 +
  }
184 +
185 +
  tags(owner: string, name: string): Promise<api.Tag[]> {
186 +
    return this.request("GET", `/repos/${owner}/${name}/tags`);
187 +
  }
188 +
177 189
  search(q: string): Promise<{ kind: string; repository: string; number: number; title: string }[]> {
178 190
    return this.request("GET", `/search?q=${encodeURIComponent(q)}`);
179 191
  }
…
75 unchanged lines
255 267
    return { kind: "other", url };
256 268
  }
257 269
  return { kind: m[4] as "proposal" | "issue", base: m[1], owner: m[2], name: m[3], number: Number(m[5]) };
270 +
}
271 +
272 +
/**
273 +
 * Release notes from history: the changes in `current` that `previous`
274 +
 * does not have, newest first, one line each (the message's subject).
275 +
 */
276 +
export function releaseNotes(current: api.ChangeSummary[], previous: api.ChangeSummary[], sinceTag?: string): string {
277 +
  const seen = new Set(previous.map((c) => c.hash));
278 +
  const lines = current
279 +
    .filter((c) => !seen.has(c.hash))
280 +
    .sort((a, b) => b.when_ms - a.when_ms)
281 +
    .map((c) => `- ${c.message.split("\n")[0].trim()} (${c.hash.slice(0, 12)})`);
282 +
  const head = sinceTag ? `Changes since ${sinceTag}:` : "Changes:";
283 +
  return lines.length ? `${head}\n\n${lines.join("\n")}\n` : "No changes since the last release.\n";
284 +
}
285 +
286 +
/** Whether a tag name reads as a pre-release (`v1.0-rc1`, `2.0.0-beta.2`, `alpha`). */
287 +
export function looksPrerelease(tag: string): boolean {
288 +
  return /(^|[-.+_])(alpha|beta|rc|pre|preview|dev)\d*([-.+_]|$)/i.test(tag) || /-(a|b|rc)\d+$/i.test(tag);
258 289
}
modified test/hub.test.mjs +33 −1
…
5 unchanged lines
6 6
import { mkdtempSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7 7
import { tmpdir } from "node:os";
8 8
import { join } from "node:path";
9 -
import { HubClient, HubError, encodeDiffQuery, decodeDiffQuery, hubTarget } from "../out/hub.mjs";
9 +
import { HubClient, HubError, encodeDiffQuery, decodeDiffQuery, hubTarget, releaseNotes, looksPrerelease } from "../out/hub.mjs";
10 10
11 11
const HUB = join(import.meta.dirname, "../../oxygenhub/target/release/oxygenhub");
12 12
const OXY = join(import.meta.dirname, "../../oxygen/target/release/oxy");
…
69 unchanged lines
82 82
    assert.ok(Array.isArray(inbox.items));
83 83
    await client.markAllNotificationsRead();
84 84
    assert.equal((await client.notifications()).unread, 0);
85 +
    // Releases: a tag pushed with oxy, then released with notes from history.
86 +
    oxy("bookmark", "switch", "main");
87 +
    oxy("tag", "create", "v1.0", "-m", "First");
88 +
    oxy("push", "origin");
89 +
    const tags = await client.tags("andy", "demo");
90 +
    assert.deepEqual(tags.map((t) => t.name), ["v1.0"]);
91 +
    assert.deepEqual(await client.releases("andy", "demo"), []);
92 +
    const hist = await client.history("andy", "demo", "v1.0", 500);
93 +
    const rel = await client.createRelease("andy", "demo", { tag: "v1.0", title: "One", notes: releaseNotes(hist.changes, []), prerelease: false });
94 +
    assert.equal(rel.tag, "v1.0");
95 +
    assert.match(rel.notes, /- base \(/);
96 +
    const rels = await client.releases("andy", "demo");
97 +
    assert.equal(rels.length, 1);
98 +
    assert.equal(rels[0].title, "One");
85 99
    const me = await client.me();
86 100
    assert.equal(me.username, "andy");
87 101
    await assert.rejects(() => new HubClient(base, "nope").me(), (e) => e instanceof HubError && e.status === 401);
…
16 unchanged lines
104 118
  assert.deepEqual(hubTarget("https://h/andy/demo/issue/3#c7"), { kind: "issue", base: "https://h", owner: "andy", name: "demo", number: 3 });
105 119
  assert.equal(hubTarget("https://h/andy/demo/releases/tag/v1").kind, "other");
106 120
  assert.equal(hubTarget("https://h/andy/demo/proposal/x").kind, "other");
121 +
});
122 +
123 +
test("release notes list the changes since the previous release, newest first", () => {
124 +
  const c = (hash, message, when_ms) => ({ hash, message, when_ms, author: {}, deps: [], head: false, signature: {} });
125 +
  const old = [c("a".repeat(64), "base", 1)];
126 +
  const now = [...old, c("b".repeat(64), "Add feature\n\nbody", 2), c("c".repeat(64), "Fix bug", 3)];
127 +
  assert.equal(releaseNotes(now, old, "v1.0"), `Changes since v1.0:\n\n- Fix bug (${"c".repeat(12)})\n- Add feature (${"b".repeat(12)})\n`);
128 +
  assert.equal(releaseNotes(old, old, "v1.0"), "No changes since the last release.\n");
129 +
  assert.match(releaseNotes(old, []), /^Changes:\n\n- base/);
130 +
});
131 +
132 +
test("pre-release tags are recognised", () => {
133 +
  for (const t of ["v1.0-rc1", "2.0.0-beta.2", "v3-alpha", "1.0.0-pre", "v2.1b3".replace("b3", "-b3")]) {
134 +
    assert.ok(looksPrerelease(t), t);
135 +
  }
136 +
  for (const t of ["v1.0", "2.0.0", "release-2026", "v1.0-final", "debate"]) {
137 +
    assert.ok(!looksPrerelease(t), t);
138 +
  }
107 139
});