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 | }); |