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) 991516a107fa
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ea61abd39035
- When
- · 2026-10-29 18:46 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
991516a107fab7d12fab499dbbeb4bec5ad7d131e124038a2a4d470f035d5e2d- Depends on
- 092b8bf9f2ac 1cc0ff44e5ca 263de209e5ba 426ddc5039b5 486c15b00314 95f69b4a3ac2 a6b460b1ec04 b2dd082c57e4 bf73998c5198 d21a85954a34 e04950748142 f0b06deac4b9 f902d5b8bfeb
- Depended on by
- nothing yet
- In bookmarks
- main
- In tags
- v0.13.0
- Edits
- 91 atoms, 5 tree operations, 18 files
Browse the tree at this change
added src/dropkit.ts +140 −0
| 1 | + | // Drops in the editor (spec §8.4): the addresses of drop files, the names |
|
| 2 | + | // shared files take, and the words the Drops view shows. No vscode imports, |
|
| 3 | + | // so it is tested with plain Node. |
|
| 4 | + | ||
| 5 | + | import type * as api from "./hubapi"; |
|
| 6 | + | import { ago } from "./flowview"; |
|
| 7 | + | ||
| 8 | + | export const DROP_SCHEME = "oxygenhub-drop"; |
|
| 9 | + | ||
| 10 | + | /** Which drop file a document is: the hub, the drop's id, and the file's name ("" for the drop itself). */ |
|
| 11 | + | export interface DropRef { |
|
| 12 | + | base: string; |
|
| 13 | + | id: string; |
|
| 14 | + | name: string; |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | /** A drop file's URI parts: `/ID/NAME`, the hub in the query. */ |
|
| 18 | + | export function dropUriParts(r: DropRef): { path: string; query: string } { |
|
| 19 | + | return { path: `/${r.id}${r.name ? `/${r.name}` : ""}`, query: new URLSearchParams({ hub: r.base }).toString() }; |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | /** Parse a drop file's URI parts back; undefined if they are not one. */ |
|
| 23 | + | export function parseDropUri(path: string, query: string): DropRef | undefined { |
|
| 24 | + | const base = new URLSearchParams(query).get("hub"); |
|
| 25 | + | const m = /^\/([0-9a-f]{24})(?:\/(.+))?$/.exec(path); |
|
| 26 | + | if (!base || !m) { |
|
| 27 | + | return undefined; |
|
| 28 | + | } |
|
| 29 | + | return { base, id: m[1], name: m[2] ?? "" }; |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | /** |
|
| 33 | + | * A drop's id from what someone pastes: the id, or its page on a hub |
|
| 34 | + | * (`https://hub/OWNER/drops/ID` or `https://hub/drops/ID`). |
|
| 35 | + | */ |
|
| 36 | + | export function dropIdOf(text: string): string | undefined { |
|
| 37 | + | const t = text.trim(); |
|
| 38 | + | if (/^[0-9a-f]{24}$/.test(t)) { |
|
| 39 | + | return t; |
|
| 40 | + | } |
|
| 41 | + | const m = /\/drops\/([0-9a-f]{24})(?:[/?#].*)?$/.exec(t); |
|
| 42 | + | return m ? m[1] : undefined; |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | /** The hub a pasted drop page is on, if it is a URL. */ |
|
| 46 | + | export function dropBaseOf(text: string): string | undefined { |
|
| 47 | + | const m = /^(https?:\/\/[^/]+)\//.exec(text.trim()); |
|
| 48 | + | return m ? m[1] : undefined; |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | /** |
|
| 52 | + | * Names for files shared as one drop: each file's path below the folder |
|
| 53 | + | * they all sit in, so two `index.ts` in different folders stay apart and a |
|
| 54 | + | * single file keeps just its name. Paths use `/`. |
|
| 55 | + | */ |
|
| 56 | + | export function shareNames(paths: string[]): string[] { |
|
| 57 | + | const parts = paths.map((p) => p.split(/[\\/]/).filter(Boolean)); |
|
| 58 | + | if (parts.length === 1) { |
|
| 59 | + | return [parts[0][parts[0].length - 1]]; |
|
| 60 | + | } |
|
| 61 | + | let common = 0; |
|
| 62 | + | const shortest = Math.min(...parts.map((p) => p.length - 1)); |
|
| 63 | + | while (common < shortest && parts.every((p) => p[common] === parts[0][common])) { |
|
| 64 | + | common++; |
|
| 65 | + | } |
|
| 66 | + | return parts.map((p) => p.slice(common).join("/")); |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | /** File extensions for a language with no file behind it (an untitled editor). */ |
|
| 70 | + | const EXTENSIONS: Record<string, string> = { |
|
| 71 | + | typescript: "ts", |
|
| 72 | + | typescriptreact: "tsx", |
|
| 73 | + | javascript: "js", |
|
| 74 | + | javascriptreact: "jsx", |
|
| 75 | + | rust: "rs", |
|
| 76 | + | python: "py", |
|
| 77 | + | go: "go", |
|
| 78 | + | java: "java", |
|
| 79 | + | c: "c", |
|
| 80 | + | cpp: "cpp", |
|
| 81 | + | csharp: "cs", |
|
| 82 | + | shellscript: "sh", |
|
| 83 | + | markdown: "md", |
|
| 84 | + | json: "json", |
|
| 85 | + | yaml: "yaml", |
|
| 86 | + | toml: "toml", |
|
| 87 | + | html: "html", |
|
| 88 | + | css: "css", |
|
| 89 | + | sql: "sql", |
|
| 90 | + | ruby: "rb", |
|
| 91 | + | php: "php", |
|
| 92 | + | swift: "swift", |
|
| 93 | + | kotlin: "kt", |
|
| 94 | + | plaintext: "txt", |
|
| 95 | + | }; |
|
| 96 | + | ||
| 97 | + | /** The name a selection is shared under: the file's own name, else `snippet.EXT` by its language. */ |
|
| 98 | + | export function snippetName(fileName: string | undefined, languageId: string): string { |
|
| 99 | + | if (fileName) { |
|
| 100 | + | const base = fileName.split(/[\\/]/).pop(); |
|
| 101 | + | if (base) { |
|
| 102 | + | return base; |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | return `snippet.${EXTENSIONS[languageId] ?? "txt"}`; |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | /** A shared selection's title: the file and its lines. */ |
|
| 109 | + | export function snippetTitle(name: string, startLine: number, endLine: number, whole: boolean): string { |
|
| 110 | + | if (whole) { |
|
| 111 | + | return name; |
|
| 112 | + | } |
|
| 113 | + | return startLine === endLine ? `${name}, line ${startLine}` : `${name}, lines ${startLine}–${endLine}`; |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | /** The text a selection shares: the selected lines' text with its shared indentation removed. */ |
|
| 117 | + | export function dedent(text: string): string { |
|
| 118 | + | const lines = text.replace(/\r\n/g, "\n").split("\n"); |
|
| 119 | + | const indents = lines.filter((l) => l.trim()).map((l) => /^[ \t]*/.exec(l)![0].length); |
|
| 120 | + | const cut = indents.length ? Math.min(...indents) : 0; |
|
| 121 | + | return lines.map((l) => l.slice(cut)).join("\n"); |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | /** The HTML that embeds a drop in another page. */ |
|
| 125 | + | export function embedSnippet(d: Pick<api.Drop, "embed_url" | "title" | "id">): string { |
|
| 126 | + | const title = (d.title || d.id).replace(/"/g, """); |
|
| 127 | + | return `<iframe src="${d.embed_url ?? ""}" title="${title}" width="100%" height="400" style="border:0" loading="lazy"></iframe>`; |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /** A drop's line in the Drops view: its title, and what it holds. */ |
|
| 131 | + | export function dropLabel(d: api.Drop): { label: string; description: string } { |
|
| 132 | + | const files = d.files.length; |
|
| 133 | + | const bits = [d.visibility === "secret" ? "secret" : "", `${files} file${files === 1 ? "" : "s"}`, d.stars ? `★ ${d.stars}` : "", d.forked_from ? "fork" : ""]; |
|
| 134 | + | return { label: d.title || d.files[0]?.name || d.id, description: bits.filter(Boolean).join(" · ") }; |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | /** A comment's line, for the comments pick. */ |
|
| 138 | + | export function commentLine(c: api.DropComment, now = Date.now()): { label: string; description: string; detail: string } { |
|
| 139 | + | return { label: c.author, description: ago(c.created_ms, now), detail: c.body.replace(/\s+/g, " ").trim() }; |
|
| 140 | + | } |
added src/drops.ts +557 −0
| 1 | + | // The Drops view (spec §8.4): your drops on each hub you are signed in to, |
|
| 2 | + | // and others' drops you open; each drop's files open as documents that save |
|
| 3 | + | // back to the hub as a new change. Share as a Drop makes one from a |
|
| 4 | + | // selection, an editor or files in the Explorer. |
|
| 5 | + | ||
| 6 | + | import * as vscode from "vscode"; |
|
| 7 | + | import type * as api from "./hubapi"; |
|
| 8 | + | import { HubAuth } from "./auth"; |
|
| 9 | + | import { DROP_SCHEME, DropRef, commentLine, dedent, dropBaseOf, dropIdOf, dropLabel, dropUriParts, embedSnippet, parseDropUri, shareNames, snippetName, snippetTitle } from "./dropkit"; |
|
| 10 | + | ||
| 11 | + | type Node = |
|
| 12 | + | | { kind: "hub"; base: string } |
|
| 13 | + | | { kind: "drop"; base: string; d: api.Drop; mine: boolean } |
|
| 14 | + | | { kind: "file"; base: string; d: api.Drop; f: api.DropFile; mine: boolean }; |
|
| 15 | + | ||
| 16 | + | export type DropNode = Extract<Node, { kind: "drop" }>; |
|
| 17 | + | export type DropFileNode = Extract<Node, { kind: "file" }>; |
|
| 18 | + | ||
| 19 | + | export function dropUri(r: DropRef): vscode.Uri { |
|
| 20 | + | return vscode.Uri.from({ scheme: DROP_SCHEME, ...dropUriParts(r) }); |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | export class DropsProvider implements vscode.TreeDataProvider<Node> { |
|
| 24 | + | private readonly emitter = new vscode.EventEmitter<void>(); |
|
| 25 | + | readonly onDidChangeTreeData = this.emitter.event; |
|
| 26 | + | /** Others' drops opened this session, by hub. */ |
|
| 27 | + | private readonly opened = new Map<string, Map<string, api.Drop>>(); |
|
| 28 | + | private me = new Map<string, string>(); |
|
| 29 | + | ||
| 30 | + | constructor(private readonly auth: HubAuth) {} |
|
| 31 | + | ||
| 32 | + | refresh(): void { |
|
| 33 | + | this.emitter.fire(); |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | /** Keep a drop in the view after opening, forking or sharing it. */ |
|
| 37 | + | remember(base: string, d: api.Drop): void { |
|
| 38 | + | if (this.me.get(base) !== d.owner) { |
|
| 39 | + | const m = this.opened.get(base) ?? new Map<string, api.Drop>(); |
|
| 40 | + | m.set(d.id, d); |
|
| 41 | + | this.opened.set(base, m); |
|
| 42 | + | } |
|
| 43 | + | this.refresh(); |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | forget(base: string, id: string): void { |
|
| 47 | + | this.opened.get(base)?.delete(id); |
|
| 48 | + | this.refresh(); |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | private hubs(): string[] { |
|
| 52 | + | return this.auth.all().map((s) => s.base); |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | getTreeItem(node: Node): vscode.TreeItem { |
|
| 56 | + | switch (node.kind) { |
|
| 57 | + | case "hub": { |
|
| 58 | + | const item = new vscode.TreeItem(node.base.replace(/^https?:\/\//, ""), vscode.TreeItemCollapsibleState.Expanded); |
|
| 59 | + | item.iconPath = new vscode.ThemeIcon("server"); |
|
| 60 | + | item.contextValue = "dropHub"; |
|
| 61 | + | return item; |
|
| 62 | + | } |
|
| 63 | + | case "drop": { |
|
| 64 | + | const { label, description } = dropLabel(node.d); |
|
| 65 | + | const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Collapsed); |
|
| 66 | + | item.id = `${node.base}/${node.d.id}`; |
|
| 67 | + | item.description = node.mine ? description : `${node.d.owner} · ${description}`; |
|
| 68 | + | item.iconPath = new vscode.ThemeIcon(node.d.visibility === "secret" ? "lock" : node.d.forked_from ? "repo-forked" : "note"); |
|
| 69 | + | const md = new vscode.MarkdownString(`**${label}** by ${node.d.owner}${node.d.description ? `\n\n${node.d.description}` : ""}\n\n${node.d.url}`); |
|
| 70 | + | item.tooltip = md; |
|
| 71 | + | // dropMine/dropOther, then whether the caller starred it. |
|
| 72 | + | item.contextValue = `${node.mine ? "dropMine" : "dropOther"}${node.d.starred ? "Starred" : ""}`; |
|
| 73 | + | return item; |
|
| 74 | + | } |
|
| 75 | + | case "file": { |
|
| 76 | + | const item = new vscode.TreeItem(node.f.name, vscode.TreeItemCollapsibleState.None); |
|
| 77 | + | item.resourceUri = dropUri({ base: node.base, id: node.d.id, name: node.f.name }); |
|
| 78 | + | item.description = node.f.size >= 1024 ? `${(node.f.size / 1024).toFixed(1)} KiB` : `${node.f.size} B`; |
|
| 79 | + | item.contextValue = node.mine ? "dropFileMine" : "dropFile"; |
|
| 80 | + | item.command = { title: "Open", command: "vscode.open", arguments: [item.resourceUri] }; |
|
| 81 | + | return item; |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | async getChildren(node?: Node): Promise<Node[]> { |
|
| 87 | + | if (!node) { |
|
| 88 | + | const hubs = this.hubs(); |
|
| 89 | + | return hubs.length === 1 ? this.dropsOf(hubs[0]) : hubs.map((base) => ({ kind: "hub" as const, base })); |
|
| 90 | + | } |
|
| 91 | + | if (node.kind === "hub") { |
|
| 92 | + | return this.dropsOf(node.base); |
|
| 93 | + | } |
|
| 94 | + | if (node.kind === "drop") { |
|
| 95 | + | return node.d.files.map((f) => ({ kind: "file" as const, base: node.base, d: node.d, f, mine: node.mine })); |
|
| 96 | + | } |
|
| 97 | + | return []; |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | private async dropsOf(base: string): Promise<Node[]> { |
|
| 101 | + | const client = this.auth.clientFor(base); |
|
| 102 | + | try { |
|
| 103 | + | if (!this.me.has(base)) { |
|
| 104 | + | this.me.set(base, (await client.me()).username); |
|
| 105 | + | } |
|
| 106 | + | const mine = await client.drops(); |
|
| 107 | + | const others = [...(this.opened.get(base)?.values() ?? [])].filter((d) => !mine.some((m) => m.id === d.id)); |
|
| 108 | + | return [...mine.map((d) => ({ kind: "drop" as const, base, d, mine: true })), ...others.map((d) => ({ kind: "drop" as const, base, d, mine: false }))]; |
|
| 109 | + | } catch (e) { |
|
| 110 | + | void vscode.window.showWarningMessage(`OxygenHub: ${(e as Error).message}`); |
|
| 111 | + | return []; |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | /** |
|
| 117 | + | * Drop files as a file system: reading fetches the drop, writing commits the |
|
| 118 | + | * file to it (PATCH, one change per save), deleting removes it. A drop's own |
|
| 119 | + | * folder lists its files. |
|
| 120 | + | */ |
|
| 121 | + | export class DropFileSystem implements vscode.FileSystemProvider { |
|
| 122 | + | private readonly emitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>(); |
|
| 123 | + | readonly onDidChangeFile = this.emitter.event; |
|
| 124 | + | private readonly cache = new Map<string, { d: api.Drop; at: number }>(); |
|
| 125 | + | ||
| 126 | + | constructor( |
|
| 127 | + | private readonly auth: HubAuth, |
|
| 128 | + | private readonly changed: (base: string, d: api.Drop) => void, |
|
| 129 | + | ) {} |
|
| 130 | + | ||
| 131 | + | private ref(uri: vscode.Uri): DropRef { |
|
| 132 | + | const r = parseDropUri(uri.path, uri.query); |
|
| 133 | + | if (!r) { |
|
| 134 | + | throw vscode.FileSystemError.FileNotFound(uri); |
|
| 135 | + | } |
|
| 136 | + | return r; |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | private async load(r: DropRef, fresh = false): Promise<api.Drop> { |
|
| 140 | + | const key = `${r.base}/${r.id}`; |
|
| 141 | + | const hit = this.cache.get(key); |
|
| 142 | + | if (hit && !fresh && Date.now() - hit.at < 2000) { |
|
| 143 | + | return hit.d; |
|
| 144 | + | } |
|
| 145 | + | const d = await this.auth.clientFor(r.base).drop(r.id); |
|
| 146 | + | this.cache.set(key, { d, at: Date.now() }); |
|
| 147 | + | return d; |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | watch(): vscode.Disposable { |
|
| 151 | + | return new vscode.Disposable(() => undefined); |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | async stat(uri: vscode.Uri): Promise<vscode.FileStat> { |
|
| 155 | + | const r = this.ref(uri); |
|
| 156 | + | const d = await this.load(r); |
|
| 157 | + | if (!r.name) { |
|
| 158 | + | return { type: vscode.FileType.Directory, ctime: d.created_ms, mtime: d.updated_ms, size: 0 }; |
|
| 159 | + | } |
|
| 160 | + | const f = d.files.find((x) => x.name === r.name); |
|
| 161 | + | if (f) { |
|
| 162 | + | return { type: vscode.FileType.File, ctime: d.created_ms, mtime: d.updated_ms, size: f.size }; |
|
| 163 | + | } |
|
| 164 | + | if (d.files.some((x) => x.name.startsWith(r.name + "/"))) { |
|
| 165 | + | return { type: vscode.FileType.Directory, ctime: d.created_ms, mtime: d.updated_ms, size: 0 }; |
|
| 166 | + | } |
|
| 167 | + | throw vscode.FileSystemError.FileNotFound(uri); |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { |
|
| 171 | + | const r = this.ref(uri); |
|
| 172 | + | const d = await this.load(r); |
|
| 173 | + | const prefix = r.name ? r.name + "/" : ""; |
|
| 174 | + | const out = new Map<string, vscode.FileType>(); |
|
| 175 | + | for (const f of d.files.filter((x) => x.name.startsWith(prefix))) { |
|
| 176 | + | const rest = f.name.slice(prefix.length); |
|
| 177 | + | const [head, ...tail] = rest.split("/"); |
|
| 178 | + | out.set(head, tail.length ? vscode.FileType.Directory : vscode.FileType.File); |
|
| 179 | + | } |
|
| 180 | + | return [...out]; |
|
| 181 | + | } |
|
| 182 | + | ||
| 183 | + | createDirectory(): void { |
|
| 184 | + | throw vscode.FileSystemError.NoPermissions("A drop's folders come from its files' names."); |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | async readFile(uri: vscode.Uri): Promise<Uint8Array> { |
|
| 188 | + | const r = this.ref(uri); |
|
| 189 | + | const d = await this.load(r, true); |
|
| 190 | + | const f = d.files.find((x) => x.name === r.name); |
|
| 191 | + | if (!f) { |
|
| 192 | + | throw vscode.FileSystemError.FileNotFound(uri); |
|
| 193 | + | } |
|
| 194 | + | // Text up to 512 KiB comes with the drop; anything else from its raw address. |
|
| 195 | + | if (f.content !== undefined && f.content !== null) { |
|
| 196 | + | return new TextEncoder().encode(f.content); |
|
| 197 | + | } |
|
| 198 | + | return this.auth.clientFor(r.base).download(f.raw_url); |
|
| 199 | + | } |
|
| 200 | + | ||
| 201 | + | async writeFile(uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean }): Promise<void> { |
|
| 202 | + | const r = this.ref(uri); |
|
| 203 | + | const d = await this.load(r); |
|
| 204 | + | const exists = d.files.some((x) => x.name === r.name); |
|
| 205 | + | if (!exists && !options.create) { |
|
| 206 | + | throw vscode.FileSystemError.FileNotFound(uri); |
|
| 207 | + | } |
|
| 208 | + | if (exists && !options.overwrite) { |
|
| 209 | + | throw vscode.FileSystemError.FileExists(uri); |
|
| 210 | + | } |
|
| 211 | + | const text = new TextDecoder("utf-8", { fatal: true }).decode(content); |
|
| 212 | + | const next = await this.auth.clientFor(r.base).editDrop(r.id, { files: [{ name: r.name, content: text }], message: `${exists ? "Edit" : "Add"} ${r.name}` }).catch((e: Error) => { |
|
| 213 | + | throw vscode.FileSystemError.Unavailable(e.message); |
|
| 214 | + | }); |
|
| 215 | + | this.cache.set(`${r.base}/${r.id}`, { d: next, at: Date.now() }); |
|
| 216 | + | this.changed(r.base, next); |
|
| 217 | + | this.emitter.fire([{ type: exists ? vscode.FileChangeType.Changed : vscode.FileChangeType.Created, uri }]); |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | async delete(uri: vscode.Uri): Promise<void> { |
|
| 221 | + | const r = this.ref(uri); |
|
| 222 | + | if (!r.name) { |
|
| 223 | + | throw vscode.FileSystemError.NoPermissions("Delete the drop from the Drops view."); |
|
| 224 | + | } |
|
| 225 | + | const next = await this.auth.clientFor(r.base).editDrop(r.id, { files: [{ name: r.name, content: null }], message: `Remove ${r.name}` }); |
|
| 226 | + | this.cache.set(`${r.base}/${r.id}`, { d: next, at: Date.now() }); |
|
| 227 | + | this.changed(r.base, next); |
|
| 228 | + | this.emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]); |
|
| 229 | + | } |
|
| 230 | + | ||
| 231 | + | rename(): void { |
|
| 232 | + | throw vscode.FileSystemError.NoPermissions("Renaming a drop's file is not supported yet; add it under the new name and remove the old."); |
|
| 233 | + | } |
|
| 234 | + | } |
|
| 235 | + | ||
| 236 | + | /** The hub to make a drop on: the only one signed in to, else ask. */ |
|
| 237 | + | async function pickHub(auth: HubAuth): Promise<string | undefined> { |
|
| 238 | + | const hubs = auth.all().map((s) => s.base); |
|
| 239 | + | if (hubs.length === 0) { |
|
| 240 | + | void vscode.window.showWarningMessage("OxygenHub: sign in to a hub first.", "Sign In").then((p) => p && vscode.commands.executeCommand("oxygenhub.signIn")); |
|
| 241 | + | return undefined; |
|
| 242 | + | } |
|
| 243 | + | return hubs.length === 1 ? hubs[0] : vscode.window.showQuickPick(hubs, { placeHolder: "Make the drop on" }); |
|
| 244 | + | } |
|
| 245 | + | ||
| 246 | + | /** Ask for a title (prefilled) and whether the drop is public or secret. */ |
|
| 247 | + | async function askTitleAndVisibility(value: string): Promise<{ title: string; secret: boolean } | undefined> { |
|
| 248 | + | const title = await vscode.window.showInputBox({ prompt: "Drop title", value, validateInput: (v) => (v.length > 200 ? "At most 200 characters" : undefined) }); |
|
| 249 | + | if (title === undefined) { |
|
| 250 | + | return undefined; |
|
| 251 | + | } |
|
| 252 | + | const remembered = vscode.workspace.getConfiguration("oxygenhub").get<string>("dropVisibility", "secret"); |
|
| 253 | + | const options = [ |
|
| 254 | + | { label: "$(lock) Secret", description: "unlisted: anyone with its address can read it", secret: true }, |
|
| 255 | + | { label: "$(globe) Public", description: "listed on your Drops page", secret: false }, |
|
| 256 | + | ].sort((a) => ((a.secret ? "secret" : "public") === remembered ? -1 : 1)); |
|
| 257 | + | const pick = await vscode.window.showQuickPick(options, { placeHolder: "Who can find it" }); |
|
| 258 | + | return pick ? { title, secret: pick.secret } : undefined; |
|
| 259 | + | } |
|
| 260 | + | ||
| 261 | + | /** A new drop is made: say so, copy its address if asked, show it in the view. */ |
|
| 262 | + | async function announce(drops: DropsProvider, base: string, d: api.Drop): Promise<void> { |
|
| 263 | + | drops.remember(base, d); |
|
| 264 | + | const copy = vscode.workspace.getConfiguration("oxygenhub").get<boolean>("copyDropLink", true); |
|
| 265 | + | if (copy) { |
|
| 266 | + | await vscode.env.clipboard.writeText(d.url); |
|
| 267 | + | } |
|
| 268 | + | const pick = await vscode.window.showInformationMessage(`OxygenHub: dropped "${d.title || d.id}"${copy ? "; its address is copied" : ""}.`, "Open on Hub", ...(copy ? [] : ["Copy Link"])); |
|
| 269 | + | if (pick === "Open on Hub") { |
|
| 270 | + | void vscode.env.openExternal(vscode.Uri.parse(d.url)); |
|
| 271 | + | } else if (pick === "Copy Link") { |
|
| 272 | + | await vscode.env.clipboard.writeText(d.url); |
|
| 273 | + | } |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | /** Share as a Drop from the editor: the selection (its lines), or the whole document when nothing is selected. */ |
|
| 277 | + | export async function shareSelection(auth: HubAuth, drops: DropsProvider, uri?: vscode.Uri): Promise<void> { |
|
| 278 | + | const editor = vscode.window.activeTextEditor; |
|
| 279 | + | const doc = editor && (!uri || editor.document.uri.toString() === uri.toString()) ? editor.document : uri ? await vscode.workspace.openTextDocument(uri) : undefined; |
|
| 280 | + | if (!doc) { |
|
| 281 | + | void vscode.window.showWarningMessage("OxygenHub: open a file, or select some code, to share as a drop."); |
|
| 282 | + | return; |
|
| 283 | + | } |
|
| 284 | + | const sel = editor && editor.document === doc ? editor.selection : undefined; |
|
| 285 | + | const whole = !sel || sel.isEmpty; |
|
| 286 | + | const name = snippetName(doc.isUntitled ? undefined : doc.fileName, doc.languageId); |
|
| 287 | + | let content = doc.getText(); |
|
| 288 | + | let start = 1; |
|
| 289 | + | let end = doc.lineCount; |
|
| 290 | + | if (!whole && sel) { |
|
| 291 | + | const last = sel.end.character === 0 && sel.end.line > sel.start.line ? sel.end.line - 1 : sel.end.line; |
|
| 292 | + | content = dedent(doc.getText(new vscode.Range(sel.start.line, 0, last, doc.lineAt(last).text.length))) + "\n"; |
|
| 293 | + | [start, end] = [sel.start.line + 1, last + 1]; |
|
| 294 | + | } |
|
| 295 | + | const base = await pickHub(auth); |
|
| 296 | + | if (!base) { |
|
| 297 | + | return; |
|
| 298 | + | } |
|
| 299 | + | const asked = await askTitleAndVisibility(snippetTitle(name, start, end, whole)); |
|
| 300 | + | if (!asked) { |
|
| 301 | + | return; |
|
| 302 | + | } |
|
| 303 | + | try { |
|
| 304 | + | const d = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "OxygenHub: dropping…" }, () => |
|
| 305 | + | auth.clientFor(base).createDrop({ title: asked.title, secret: asked.secret, files: [{ name, content }] }), |
|
| 306 | + | ); |
|
| 307 | + | await announce(drops, base, d); |
|
| 308 | + | } catch (e) { |
|
| 309 | + | void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); |
|
| 310 | + | } |
|
| 311 | + | } |
|
| 312 | + | ||
| 313 | + | /** Share files (the Explorer's selection, folders' files included) as one drop. */ |
|
| 314 | + | export async function shareFiles(auth: HubAuth, drops: DropsProvider, uri?: vscode.Uri, uris?: vscode.Uri[]): Promise<void> { |
|
| 315 | + | let picked = uris && uris.length ? uris : uri ? [uri] : vscode.window.activeTextEditor ? [vscode.window.activeTextEditor.document.uri] : []; |
|
| 316 | + | if (!picked.length) { |
|
| 317 | + | picked = (await vscode.window.showOpenDialog({ canSelectMany: true, openLabel: "Share as a Drop" })) ?? []; |
|
| 318 | + | } |
|
| 319 | + | const files: vscode.Uri[] = []; |
|
| 320 | + | for (const u of picked) { |
|
| 321 | + | const stat = await vscode.workspace.fs.stat(u); |
|
| 322 | + | if (stat.type & vscode.FileType.Directory) { |
|
| 323 | + | files.push(...(await vscode.workspace.findFiles(new vscode.RelativePattern(u, "**/*"), "**/{.oxy,.git,node_modules,target}/**", 51))); |
|
| 324 | + | } else { |
|
| 325 | + | files.push(u); |
|
| 326 | + | } |
|
| 327 | + | } |
|
| 328 | + | if (!files.length) { |
|
| 329 | + | return; |
|
| 330 | + | } |
|
| 331 | + | if (files.length > 50) { |
|
| 332 | + | void vscode.window.showWarningMessage("OxygenHub: a drop takes at most 50 files; push larger sets with oxy."); |
|
| 333 | + | return; |
|
| 334 | + | } |
|
| 335 | + | const names = shareNames(files.map((f) => f.path)); |
|
| 336 | + | const contents: api.NewDropFile[] = []; |
|
| 337 | + | for (const [i, f] of files.entries()) { |
|
| 338 | + | const bytes = await vscode.workspace.fs.readFile(f); |
|
| 339 | + | if (bytes.length > 1 << 20) { |
|
| 340 | + | void vscode.window.showWarningMessage(`OxygenHub: ${names[i]} is larger than 1 MiB; a drop's files are at most that.`); |
|
| 341 | + | return; |
|
| 342 | + | } |
|
| 343 | + | try { |
|
| 344 | + | contents.push({ name: names[i], content: new TextDecoder("utf-8", { fatal: true }).decode(bytes) }); |
|
| 345 | + | } catch { |
|
| 346 | + | void vscode.window.showWarningMessage(`OxygenHub: ${names[i]} is not text; a drop shares text files.`); |
|
| 347 | + | return; |
|
| 348 | + | } |
|
| 349 | + | } |
|
| 350 | + | const base = await pickHub(auth); |
|
| 351 | + | if (!base) { |
|
| 352 | + | return; |
|
| 353 | + | } |
|
| 354 | + | const asked = await askTitleAndVisibility(names.length === 1 ? names[0] : `${names[0]} and ${names.length - 1} more`); |
|
| 355 | + | if (!asked) { |
|
| 356 | + | return; |
|
| 357 | + | } |
|
| 358 | + | try { |
|
| 359 | + | const d = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "OxygenHub: dropping…" }, () => |
|
| 360 | + | auth.clientFor(base).createDrop({ title: asked.title, secret: asked.secret, files: contents }), |
|
| 361 | + | ); |
|
| 362 | + | await announce(drops, base, d); |
|
| 363 | + | } catch (e) { |
|
| 364 | + | void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); |
|
| 365 | + | } |
|
| 366 | + | } |
|
| 367 | + | ||
| 368 | + | /** The drop commands that act on the view's items. */ |
|
| 369 | + | export function registerDropCommands(auth: HubAuth, drops: DropsProvider): vscode.Disposable[] { |
|
| 370 | + | const client = (base: string) => auth.clientFor(base); |
|
| 371 | + | const guard = |
|
| 372 | + | <A extends unknown[]>(f: (...a: A) => Promise<unknown>) => |
|
| 373 | + | async (...a: A) => { |
|
| 374 | + | try { |
|
| 375 | + | await f(...a); |
|
| 376 | + | } catch (e) { |
|
| 377 | + | void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); |
|
| 378 | + | } |
|
| 379 | + | }; |
|
| 380 | + | const open = async (base: string, d: api.Drop) => { |
|
| 381 | + | drops.remember(base, d); |
|
| 382 | + | if (d.files.length) { |
|
| 383 | + | await vscode.window.showTextDocument(dropUri({ base, id: d.id, name: d.files[0].name }), { preview: false }); |
|
| 384 | + | } |
|
| 385 | + | }; |
|
| 386 | + | return [ |
|
| 387 | + | vscode.commands.registerCommand("oxygenhub.dropsRefresh", () => drops.refresh()), |
|
| 388 | + | vscode.commands.registerCommand( |
|
| 389 | + | "oxygenhub.newDrop", |
|
| 390 | + | guard(async () => { |
|
| 391 | + | const base = await pickHub(auth); |
|
| 392 | + | if (!base) { |
|
| 393 | + | return; |
|
| 394 | + | } |
|
| 395 | + | const name = await vscode.window.showInputBox({ prompt: "First file's name", value: "notes.md", validateInput: (v) => (v.trim() ? undefined : "Every file needs a name") }); |
|
| 396 | + | if (!name) { |
|
| 397 | + | return; |
|
| 398 | + | } |
|
| 399 | + | const asked = await askTitleAndVisibility(""); |
|
| 400 | + | if (!asked) { |
|
| 401 | + | return; |
|
| 402 | + | } |
|
| 403 | + | const d = await client(base).createDrop({ title: asked.title, secret: asked.secret, files: [{ name: name.trim(), content: "" }] }); |
|
| 404 | + | drops.refresh(); |
|
| 405 | + | await open(base, d); |
|
| 406 | + | }), |
|
| 407 | + | ), |
|
| 408 | + | vscode.commands.registerCommand( |
|
| 409 | + | "oxygenhub.openDrop", |
|
| 410 | + | guard(async () => { |
|
| 411 | + | const text = await vscode.window.showInputBox({ prompt: "A drop's address or id", placeHolder: "https://hub/OWNER/drops/ID", validateInput: (v) => (dropIdOf(v) ? undefined : "A drop's page or its 24-digit id") }); |
|
| 412 | + | if (!text) { |
|
| 413 | + | return; |
|
| 414 | + | } |
|
| 415 | + | const id = dropIdOf(text)!; |
|
| 416 | + | const pasted = dropBaseOf(text); |
|
| 417 | + | const base = pasted && auth.bases().includes(pasted) ? pasted : pasted ?? (await pickHub(auth)); |
|
| 418 | + | if (!base) { |
|
| 419 | + | return; |
|
| 420 | + | } |
|
| 421 | + | await open(base, await client(base).drop(id)); |
|
| 422 | + | }), |
|
| 423 | + | ), |
|
| 424 | + | vscode.commands.registerCommand("oxygenhub.openDropOnHub", (n: DropNode) => vscode.env.openExternal(vscode.Uri.parse(n.d.url))), |
|
| 425 | + | vscode.commands.registerCommand("oxygenhub.copyDropLink", async (n: DropNode) => { |
|
| 426 | + | await vscode.env.clipboard.writeText(n.d.url); |
|
| 427 | + | vscode.window.setStatusBarMessage("$(link) Drop address copied", 3000); |
|
| 428 | + | }), |
|
| 429 | + | vscode.commands.registerCommand("oxygenhub.copyDropClone", async (n: DropNode) => { |
|
| 430 | + | await vscode.env.clipboard.writeText(`oxy clone ${n.d.clone_url}`); |
|
| 431 | + | vscode.window.setStatusBarMessage("$(terminal) oxy clone command copied", 3000); |
|
| 432 | + | }), |
|
| 433 | + | vscode.commands.registerCommand("oxygenhub.copyDropEmbed", async (n: DropNode) => { |
|
| 434 | + | await vscode.env.clipboard.writeText(embedSnippet(n.d)); |
|
| 435 | + | vscode.window.setStatusBarMessage("$(code) Embed HTML copied", 3000); |
|
| 436 | + | }), |
|
| 437 | + | vscode.commands.registerCommand( |
|
| 438 | + | "oxygenhub.cloneDrop", |
|
| 439 | + | guard(async (n: DropNode) => { |
|
| 440 | + | const parent = await vscode.window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, openLabel: "Clone Here" }); |
|
| 441 | + | if (!parent?.[0]) { |
|
| 442 | + | return; |
|
| 443 | + | } |
|
| 444 | + | const t = vscode.window.createTerminal({ name: "OxygenHub", cwd: parent[0].fsPath }); |
|
| 445 | + | t.show(); |
|
| 446 | + | t.sendText(`oxy clone ${n.d.clone_url} drop-${n.d.id.slice(0, 8)}`); |
|
| 447 | + | }), |
|
| 448 | + | ), |
|
| 449 | + | vscode.commands.registerCommand( |
|
| 450 | + | "oxygenhub.editDrop", |
|
| 451 | + | guard(async (n: DropNode) => { |
|
| 452 | + | const title = await vscode.window.showInputBox({ prompt: "Title", value: n.d.title }); |
|
| 453 | + | if (title === undefined) { |
|
| 454 | + | return; |
|
| 455 | + | } |
|
| 456 | + | const description = await vscode.window.showInputBox({ prompt: "Description", value: n.d.description }); |
|
| 457 | + | if (description === undefined) { |
|
| 458 | + | return; |
|
| 459 | + | } |
|
| 460 | + | await client(n.base).editDrop(n.d.id, { title, description }); |
|
| 461 | + | drops.refresh(); |
|
| 462 | + | }), |
|
| 463 | + | ), |
|
| 464 | + | vscode.commands.registerCommand( |
|
| 465 | + | "oxygenhub.toggleDropVisibility", |
|
| 466 | + | guard(async (n: DropNode) => { |
|
| 467 | + | const secret = n.d.visibility !== "secret"; |
|
| 468 | + | await client(n.base).editDrop(n.d.id, { secret }); |
|
| 469 | + | drops.refresh(); |
|
| 470 | + | vscode.window.setStatusBarMessage(secret ? "$(lock) The drop is secret now" : "$(globe) The drop is public now", 3000); |
|
| 471 | + | }), |
|
| 472 | + | ), |
|
| 473 | + | vscode.commands.registerCommand( |
|
| 474 | + | "oxygenhub.addDropFile", |
|
| 475 | + | guard(async (n: DropNode) => { |
|
| 476 | + | const name = await vscode.window.showInputBox({ prompt: `New file in "${n.d.title || n.d.id}"`, validateInput: (v) => (!v.trim() ? "Every file needs a name" : n.d.files.some((f) => f.name === v.trim()) ? "The drop has a file by that name" : undefined) }); |
|
| 477 | + | if (!name) { |
|
| 478 | + | return; |
|
| 479 | + | } |
|
| 480 | + | await client(n.base).editDrop(n.d.id, { files: [{ name: name.trim(), content: "" }], message: `Add ${name.trim()}` }); |
|
| 481 | + | drops.refresh(); |
|
| 482 | + | await vscode.window.showTextDocument(dropUri({ base: n.base, id: n.d.id, name: name.trim() }), { preview: false }); |
|
| 483 | + | }), |
|
| 484 | + | ), |
|
| 485 | + | vscode.commands.registerCommand( |
|
| 486 | + | "oxygenhub.deleteDropFile", |
|
| 487 | + | guard(async (n: DropFileNode) => { |
|
| 488 | + | if (n.d.files.length === 1) { |
|
| 489 | + | void vscode.window.showWarningMessage("OxygenHub: a drop keeps at least one file; delete the drop instead."); |
|
| 490 | + | return; |
|
| 491 | + | } |
|
| 492 | + | const ok = await vscode.window.showWarningMessage(`Remove ${n.f.name} from the drop?`, { modal: true, detail: "Its history keeps it." }, "Remove"); |
|
| 493 | + | if (ok) { |
|
| 494 | + | await client(n.base).editDrop(n.d.id, { files: [{ name: n.f.name, content: null }], message: `Remove ${n.f.name}` }); |
|
| 495 | + | drops.refresh(); |
|
| 496 | + | } |
|
| 497 | + | }), |
|
| 498 | + | ), |
|
| 499 | + | vscode.commands.registerCommand( |
|
| 500 | + | "oxygenhub.deleteDrop", |
|
| 501 | + | guard(async (n: DropNode) => { |
|
| 502 | + | const ok = await vscode.window.showWarningMessage(`Delete the drop "${n.d.title || n.d.id}"?`, { modal: true, detail: "It and its history are gone for good, and its address stops working." }, "Delete"); |
|
| 503 | + | if (ok) { |
|
| 504 | + | await client(n.base).deleteDrop(n.d.id); |
|
| 505 | + | drops.forget(n.base, n.d.id); |
|
| 506 | + | } |
|
| 507 | + | }), |
|
| 508 | + | ), |
|
| 509 | + | vscode.commands.registerCommand( |
|
| 510 | + | "oxygenhub.forkDrop", |
|
| 511 | + | guard(async (n: DropNode) => { |
|
| 512 | + | const d = await client(n.base).forkDrop(n.d.id); |
|
| 513 | + | drops.refresh(); |
|
| 514 | + | await open(n.base, d); |
|
| 515 | + | vscode.window.setStatusBarMessage(`$(repo-forked) Forked "${n.d.title || n.d.id}"`, 3000); |
|
| 516 | + | }), |
|
| 517 | + | ), |
|
| 518 | + | vscode.commands.registerCommand( |
|
| 519 | + | "oxygenhub.starDrop", |
|
| 520 | + | guard(async (n: DropNode) => { |
|
| 521 | + | const s = await client(n.base).starDrop(n.d.id, true); |
|
| 522 | + | drops.remember(n.base, { ...n.d, ...s }); |
|
| 523 | + | }), |
|
| 524 | + | ), |
|
| 525 | + | vscode.commands.registerCommand( |
|
| 526 | + | "oxygenhub.unstarDrop", |
|
| 527 | + | guard(async (n: DropNode) => { |
|
| 528 | + | const s = await client(n.base).starDrop(n.d.id, false); |
|
| 529 | + | drops.remember(n.base, { ...n.d, ...s }); |
|
| 530 | + | }), |
|
| 531 | + | ), |
|
| 532 | + | vscode.commands.registerCommand( |
|
| 533 | + | "oxygenhub.dropComments", |
|
| 534 | + | guard(async (n: DropNode) => { |
|
| 535 | + | const c = client(n.base); |
|
| 536 | + | const me = (await c.me()).username; |
|
| 537 | + | const comments = await c.dropComments(n.d.id); |
|
| 538 | + | type Item = vscode.QuickPickItem & { add?: boolean; comment?: api.DropComment }; |
|
| 539 | + | const items: Item[] = [{ label: "$(comment) Add a comment…", add: true }, ...comments.map((x) => ({ ...commentLine(x), comment: x }))]; |
|
| 540 | + | const pick = await vscode.window.showQuickPick(items, { placeHolder: `${comments.length} comment${comments.length === 1 ? "" : "s"} on "${n.d.title || n.d.id}"`, matchOnDetail: true }); |
|
| 541 | + | if (pick?.add) { |
|
| 542 | + | const body = await vscode.window.showInputBox({ prompt: "Comment (Markdown)" }); |
|
| 543 | + | if (body?.trim()) { |
|
| 544 | + | await c.commentDrop(n.d.id, body); |
|
| 545 | + | vscode.window.setStatusBarMessage("$(comment) Commented", 3000); |
|
| 546 | + | } |
|
| 547 | + | } else if (pick?.comment) { |
|
| 548 | + | const mayDelete = pick.comment.author === me || n.d.owner === me; |
|
| 549 | + | const act = await vscode.window.showInformationMessage(`${pick.comment.author}: ${pick.comment.body}`, ...(mayDelete ? ["Delete Comment"] : [])); |
|
| 550 | + | if (act === "Delete Comment") { |
|
| 551 | + | await c.deleteDropComment(n.d.id, pick.comment.id); |
|
| 552 | + | } |
|
| 553 | + | } |
|
| 554 | + | }), |
|
| 555 | + | ), |
|
| 556 | + | ]; |
|
| 557 | + | } |
added src/newrepo.ts +93 −0
| 1 | + | // New Repository… (spec §8.5): a repository on a hub under you or one of |
|
| 2 | + | // your organisations, optionally with an assistant's starting files (#347), |
|
| 3 | + | // then cloned into a folder you choose. |
|
| 4 | + | ||
| 5 | + | import * as vscode from "vscode"; |
|
| 6 | + | import type * as api from "./hubapi"; |
|
| 7 | + | import { HubAuth } from "./auth"; |
|
| 8 | + | ||
| 9 | + | const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; |
|
| 10 | + | ||
| 11 | + | export async function newRepository(auth: HubAuth): Promise<void> { |
|
| 12 | + | const hubs = auth.all().map((s) => s.base); |
|
| 13 | + | const base = hubs.length === 1 ? hubs[0] : await vscode.window.showQuickPick(hubs, { placeHolder: "Create the repository on" }); |
|
| 14 | + | if (!base) { |
|
| 15 | + | if (!hubs.length) { |
|
| 16 | + | void vscode.window.showWarningMessage("OxygenHub: sign in to a hub first."); |
|
| 17 | + | } |
|
| 18 | + | return; |
|
| 19 | + | } |
|
| 20 | + | const client = auth.clientFor(base); |
|
| 21 | + | const [me, orgs] = await Promise.all([client.me(), client.orgs().catch(() => [] as api.Organisation[])]); |
|
| 22 | + | const owner = orgs.length |
|
| 23 | + | ? (await vscode.window.showQuickPick([{ label: me.username, description: "you" }, ...orgs.map((o) => ({ label: o.name, description: o.display_name }))], { placeHolder: "Owner" }))?.label |
|
| 24 | + | : me.username; |
|
| 25 | + | if (!owner) { |
|
| 26 | + | return; |
|
| 27 | + | } |
|
| 28 | + | const name = await vscode.window.showInputBox({ prompt: `Repository name under ${owner}`, validateInput: (v) => (NAME.test(v) ? undefined : "Letters, digits, '.', '_' and '-', starting with a letter or digit") }); |
|
| 29 | + | if (!name) { |
|
| 30 | + | return; |
|
| 31 | + | } |
|
| 32 | + | const description = (await vscode.window.showInputBox({ prompt: "What is it for? (optional; an assistant can draft its starting files from this)" })) ?? ""; |
|
| 33 | + | const vis = await vscode.window.showQuickPick( |
|
| 34 | + | [ |
|
| 35 | + | { label: "$(lock) Private", value: "private" as const }, |
|
| 36 | + | { label: "$(organization) Internal", description: "everyone signed in to the hub", value: "internal" as const }, |
|
| 37 | + | { label: "$(globe) Public", value: "public" as const }, |
|
| 38 | + | ], |
|
| 39 | + | { placeHolder: "Visibility" }, |
|
| 40 | + | ); |
|
| 41 | + | if (!vis) { |
|
| 42 | + | return; |
|
| 43 | + | } |
|
| 44 | + | try { |
|
| 45 | + | const repo = await client.createRepository({ owner, name, description, visibility: vis.value }); |
|
| 46 | + | if (description.trim()) { |
|
| 47 | + | await offerStarter(client, repo, description.trim()); |
|
| 48 | + | } |
|
| 49 | + | const pick = await vscode.window.showInformationMessage(`OxygenHub: created ${owner}/${name}.`, "Clone", "Open on Hub"); |
|
| 50 | + | if (pick === "Open on Hub") { |
|
| 51 | + | void vscode.env.openExternal(vscode.Uri.parse(`${base}/${owner}/${name}`)); |
|
| 52 | + | } else if (pick === "Clone") { |
|
| 53 | + | const parent = await vscode.window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, openLabel: "Clone Here" }); |
|
| 54 | + | if (parent?.[0]) { |
|
| 55 | + | const t = vscode.window.createTerminal({ name: "OxygenHub", cwd: parent[0].fsPath }); |
|
| 56 | + | t.show(); |
|
| 57 | + | t.sendText(`oxy clone ${repo.clone_url} ${name}`); |
|
| 58 | + | } |
|
| 59 | + | } |
|
| 60 | + | } catch (e) { |
|
| 61 | + | void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); |
|
| 62 | + | } |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | /** When the owner has an assistant, offer its starting files, and wait for them. */ |
|
| 66 | + | async function offerStarter(client: ReturnType<HubAuth["clientFor"]>, repo: api.Repository, description: string): Promise<void> { |
|
| 67 | + | const { assistants } = await client.starterAssistants(repo.owner).catch(() => ({ assistants: [] as api.StarterAssistant[] })); |
|
| 68 | + | if (!assistants.length) { |
|
| 69 | + | return; |
|
| 70 | + | } |
|
| 71 | + | const pick = await vscode.window.showQuickPick( |
|
| 72 | + | [{ label: "$(close) No, start empty" }, ...assistants.map((a) => ({ label: `$(sparkle) Ask ${a.name}`, description: `${a.model}, from ${a.from}`, assistant: a.name }))], |
|
| 73 | + | { placeHolder: "Ask an assistant for its starting files (a README, build files, a first test)?" }, |
|
| 74 | + | ); |
|
| 75 | + | if (!pick || !("assistant" in pick)) { |
|
| 76 | + | return; |
|
| 77 | + | } |
|
| 78 | + | await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `OxygenHub: ${pick.assistant} is drafting the starting files…`, cancellable: true }, async (_p, token) => { |
|
| 79 | + | let s = await client.startStarter(repo.owner, repo.name, { description, assistant: pick.assistant }); |
|
| 80 | + | // A model may take a few minutes; the hub commits the files when it is done. |
|
| 81 | + | for (let i = 0; i < 120 && s.status === "running" && !token.isCancellationRequested; i++) { |
|
| 82 | + | await new Promise((r) => setTimeout(r, 3000)); |
|
| 83 | + | s = await client.starterStatus(repo.owner, repo.name); |
|
| 84 | + | } |
|
| 85 | + | if (s.status === "done") { |
|
| 86 | + | void vscode.window.showInformationMessage(`OxygenHub: ${s.assistant} committed ${s.files?.length ?? 0} starting file(s).`); |
|
| 87 | + | } else if (s.status === "failed") { |
|
| 88 | + | void vscode.window.showWarningMessage(`OxygenHub: the starter failed: ${s.error}`); |
|
| 89 | + | } else if (s.status === "running") { |
|
| 90 | + | void vscode.window.showInformationMessage("OxygenHub: the starter is still being drafted; the hub commits it when done."); |
|
| 91 | + | } |
|
| 92 | + | }); |
|
| 93 | + | } |
added src/tests.ts +77 −0
| 1 | + | // Flow's test reports in VS Code's Testing view (spec §9.2): the newest |
|
| 2 | + | // report on each repository's current bookmark, its suites and cases with |
|
| 3 | + | // their results, messages and output. Nothing runs here: running a test |
|
| 4 | + | // loads the hub's latest results. |
|
| 5 | + | ||
| 6 | + | import * as vscode from "vscode"; |
|
| 7 | + | import { HubAuth } from "./auth"; |
|
| 8 | + | import { caseId, caseOutcome, casesBySuite, duration } from "./flowview"; |
|
| 9 | + | import type { HubRepo } from "./proposals"; |
|
| 10 | + | ||
| 11 | + | export class HubTests implements vscode.Disposable { |
|
| 12 | + | private readonly controller: vscode.TestController; |
|
| 13 | + | ||
| 14 | + | constructor( |
|
| 15 | + | private readonly auth: HubAuth, |
|
| 16 | + | private readonly repos: () => Promise<HubRepo[]>, |
|
| 17 | + | ) { |
|
| 18 | + | this.controller = vscode.tests.createTestController("oxygenhub", "OxygenFlow test reports"); |
|
| 19 | + | this.controller.refreshHandler = () => this.load(); |
|
| 20 | + | this.controller.createRunProfile("Load the hub's latest results", vscode.TestRunProfileKind.Run, () => this.load(), true); |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | dispose(): void { |
|
| 24 | + | this.controller.dispose(); |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | /** Fetch each repository's newest test report and show its results. */ |
|
| 28 | + | async load(): Promise<void> { |
|
| 29 | + | const repos = await this.repos().catch(() => [] as HubRepo[]); |
|
| 30 | + | const roots: vscode.TestItem[] = []; |
|
| 31 | + | const results: [vscode.TestItem, string, string, string, number][] = []; |
|
| 32 | + | for (const repo of repos) { |
|
| 33 | + | const client = this.auth.clientFor(repo.base); |
|
| 34 | + | const bookmark = repo.handle.currentBookmark ?? undefined; |
|
| 35 | + | const [newest] = await client.testReports(repo.owner, repo.name, bookmark, 1).catch(() => []); |
|
| 36 | + | if (!newest) { |
|
| 37 | + | continue; |
|
| 38 | + | } |
|
| 39 | + | const detail = await client.testReport(repo.owner, repo.name, newest.id).catch(() => undefined); |
|
| 40 | + | if (!detail) { |
|
| 41 | + | continue; |
|
| 42 | + | } |
|
| 43 | + | const key = `${repo.base}/${repo.owner}/${repo.name}`; |
|
| 44 | + | const r = detail.report; |
|
| 45 | + | const root = this.controller.createTestItem(key, `${repo.owner}/${repo.name}`); |
|
| 46 | + | root.description = `${r.passed}/${r.tests} passed${r.failed ? ` · ${r.failed} failed` : ""} · run #${r.run} ${r.job} on ${r.bookmark}`; |
|
| 47 | + | for (const [suite, cases] of casesBySuite(detail.cases)) { |
|
| 48 | + | const s = this.controller.createTestItem(`${key}::${suite}`, suite); |
|
| 49 | + | for (const c of cases) { |
|
| 50 | + | const item = this.controller.createTestItem(caseId(key, c), c.name); |
|
| 51 | + | item.description = c.classname && c.classname !== suite ? c.classname : undefined; |
|
| 52 | + | s.children.add(item); |
|
| 53 | + | results.push([item, c.status, c.message, c.output, c.time_ms]); |
|
| 54 | + | } |
|
| 55 | + | root.children.add(s); |
|
| 56 | + | } |
|
| 57 | + | roots.push(root); |
|
| 58 | + | } |
|
| 59 | + | this.controller.items.replace(roots); |
|
| 60 | + | const run = this.controller.createTestRun(new vscode.TestRunRequest(), "OxygenFlow", false); |
|
| 61 | + | for (const [item, status, message, output, ms] of results) { |
|
| 62 | + | const outcome = caseOutcome(status); |
|
| 63 | + | if (outcome === "passed") { |
|
| 64 | + | run.passed(item, ms); |
|
| 65 | + | } else if (outcome === "skipped") { |
|
| 66 | + | run.skipped(item); |
|
| 67 | + | } else { |
|
| 68 | + | const m = new vscode.TestMessage(message || `${status} after ${duration(ms)}`); |
|
| 69 | + | if (output) { |
|
| 70 | + | m.actualOutput = output; |
|
| 71 | + | } |
|
| 72 | + | (outcome === "failed" ? run.failed : run.errored).call(run, item, m, ms); |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | run.end(); |
|
| 76 | + | } |
|
| 77 | + | } |
added test/dropkit.test.mjs +50 −0
| 1 | + | import { test } from "node:test"; |
|
| 2 | + | import assert from "node:assert/strict"; |
|
| 3 | + | import { dedent, dropBaseOf, dropIdOf, dropLabel, dropUriParts, embedSnippet, parseDropUri, shareNames, snippetName, snippetTitle, commentLine } from "../out/dropkit.mjs"; |
|
| 4 | + | ||
| 5 | + | const ID = "0123456789abcdef01234567"; |
|
| 6 | + | ||
| 7 | + | test("drop file addresses round trip, folders and all", () => { |
|
| 8 | + | const r = { base: "https://hub.example:7343", id: ID, name: "src/lib/a.ts" }; |
|
| 9 | + | const { path, query } = dropUriParts(r); |
|
| 10 | + | assert.equal(path, `/${ID}/src/lib/a.ts`); |
|
| 11 | + | assert.deepEqual(parseDropUri(path, query), r); |
|
| 12 | + | assert.deepEqual(parseDropUri(`/${ID}`, query), { ...r, name: "" }, "the drop itself"); |
|
| 13 | + | assert.equal(parseDropUri("/not-an-id/a.ts", query), undefined); |
|
| 14 | + | assert.equal(parseDropUri(path, ""), undefined, "no hub, no drop"); |
|
| 15 | + | }); |
|
| 16 | + | ||
| 17 | + | test("a pasted drop is its id or its page", () => { |
|
| 18 | + | assert.equal(dropIdOf(ID), ID); |
|
| 19 | + | assert.equal(dropIdOf(` https://oxygenhub.io/andy/drops/${ID} `), ID); |
|
| 20 | + | assert.equal(dropIdOf(`https://oxygenhub.io/drops/${ID}?tab=history`), ID); |
|
| 21 | + | assert.equal(dropIdOf("https://oxygenhub.io/andy/oxygen"), undefined); |
|
| 22 | + | assert.equal(dropBaseOf(`https://oxygenhub.io/andy/drops/${ID}`), "https://oxygenhub.io"); |
|
| 23 | + | assert.equal(dropBaseOf(ID), undefined); |
|
| 24 | + | }); |
|
| 25 | + | ||
| 26 | + | test("shared files keep their paths below the folder they share", () => { |
|
| 27 | + | assert.deepEqual(shareNames(["/w/p/src/a/index.ts"]), ["index.ts"], "one file: its name"); |
|
| 28 | + | assert.deepEqual(shareNames(["/w/p/src/a/index.ts", "/w/p/src/b/index.ts"]), ["a/index.ts", "b/index.ts"], "two index.ts stay apart"); |
|
| 29 | + | assert.deepEqual(shareNames(["/w/p/README.md", "/w/p/src/main.rs"]), ["README.md", "src/main.rs"]); |
|
| 30 | + | assert.deepEqual(shareNames(["C:\\w\\p\\a.txt", "C:\\w\\p\\b.txt"]), ["a.txt", "b.txt"], "Windows paths"); |
|
| 31 | + | }); |
|
| 32 | + | ||
| 33 | + | test("a selection is named and titled by its file and lines", () => { |
|
| 34 | + | assert.equal(snippetName("/w/p/src/main.rs", "rust"), "main.rs"); |
|
| 35 | + | assert.equal(snippetName(undefined, "python"), "snippet.py", "an untitled editor by its language"); |
|
| 36 | + | assert.equal(snippetName(undefined, "cobol"), "snippet.txt"); |
|
| 37 | + | assert.equal(snippetTitle("main.rs", 3, 9, false), "main.rs, lines 3–9"); |
|
| 38 | + | assert.equal(snippetTitle("main.rs", 3, 3, false), "main.rs, line 3"); |
|
| 39 | + | assert.equal(snippetTitle("main.rs", 1, 40, true), "main.rs"); |
|
| 40 | + | assert.equal(dedent(" fn a() {\n b();\n\n }"), "fn a() {\n b();\n\n}", "shared indentation goes, blank lines stay"); |
|
| 41 | + | }); |
|
| 42 | + | ||
| 43 | + | test("a drop's line, embed and comments", () => { |
|
| 44 | + | const d = { id: ID, owner: "andy", title: "", description: "", visibility: "secret", url: "", clone_url: "", created_ms: 0, updated_ms: 0, files: [{ name: "a.md", size: 3, raw_url: "" }], stars: 2, starred: false, forked_from: "/x/drops/y", embed_url: "https://hub/andy/drops/x/embed" }; |
|
| 45 | + | assert.deepEqual(dropLabel(d), { label: "a.md", description: "secret · 1 file · ★ 2 · fork" }); |
|
| 46 | + | assert.deepEqual(dropLabel({ ...d, title: "Notes", visibility: "public", stars: 0, forked_from: null, files: [...d.files, d.files[0]] }), { label: "Notes", description: "2 files" }); |
|
| 47 | + | assert.equal(embedSnippet({ ...d, title: 'say "hi"' }), '<iframe src="https://hub/andy/drops/x/embed" title="say "hi"" width="100%" height="400" style="border:0" loading="lazy"></iframe>'); |
|
| 48 | + | const line = commentLine({ id: 1, author: "sky", body: "nice\n\nthanks", created_ms: 0 }, 10_000); |
|
| 49 | + | assert.deepEqual(line, { label: "sky", description: "just now", detail: "nice thanks" }); |
|
| 50 | + | }); |
modified CHANGELOG.md +6 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## 0.13.0 (2026-10-29) |
|
| 4 | + | ||
| 5 | + | - **Drops in the editor (#7).** A **Drops** view lists your drops and the ones you open; a drop's files open as documents, and **saving commits** them to the drop. **Share as a Drop…** shares a selection or a file from the editor, **Share Files as a Drop…** files or a folder from the Explorer, secret or public, with the address copied for you. From a drop's menu: add and remove files, edit its title, make it public or secret, copy its link, `oxy clone` command or embed HTML, clone it, read and add comments, star it, and fork someone else's. Needs OxygenHub 0.9.17. |
|
| 6 | + | - **Flow: approvals, expedite, reports, timings and test results (#8).** Approve or reject a job waiting on its approval rules, expedite or cancel a run, see where a job's time went in its tooltip, and open the reports it published. Flow's test reports appear in VS Code's **Testing** view, suite by suite, with failure messages and output. |
|
| 7 | + | - **Proposals, releases and repositories (#9).** **Mark Ready for Review** and **Convert to Draft**; **Ready to Land…** lists what you can land now across your hubs. A release's **Show Trust Report** shows its signatures, builds and audit; **Save Asset…** downloads an asset, private ones included. **New Repository…** creates one under you or an organisation, optionally with an assistant's starting files. |
|
| 8 | + | ||
| 3 | 9 | ## 0.12.0 (2026-10-13) |
|
| 4 | 10 | ||
| 5 | 11 | - **A Diffusion view (#5).** What each repository takes from and diffuses to, each row reading like the hub's pill (`untested │ passes`), how far behind it is and its open bump; picking a row opens the bump. Needs an OxygenHub with `GET /repos/{o}/{n}/diffusion` (oxygenhub#153). |
|
| … | 99 unchanged lines |
modified README.md +24 −1
| … | 10 unchanged lines |
||
| 11 | 11 | ## Getting started |
|
| 12 | 12 | ||
| 13 | 13 | 1. Run **OxygenHub: Sign in**, give the hub's URL (for example `https://earth.example.org:7343`) and paste the token. The token is kept in VS Code's secret storage, never in settings. |
|
| 14 | - | 2. Open a repository whose remote is on that hub. The **OxygenHub** activity bar icon shows Proposals, Issues, Releases, Flow and Diffusion for it. |
|
| 14 | + | 2. Open a repository whose remote is on that hub. The **OxygenHub** activity bar icon shows Proposals, Issues, Releases, Flow, Diffusion and Drops for it, and Flow's test reports appear in the Testing view. |
|
| 15 | 15 | 3. **Open on Remote** and **Copy Permalink** in the Oxygen VCS extension now open pages on the hub. |
|
| 16 | 16 | ||
| 17 | 17 | ## Walkthroughs |
|
| … | 26 unchanged lines |
||
| 44 | 44 | - Typing `#` or `@` in the commit box or a comment completes issue, proposal and user names. Hovering `#N` shows what it is. |
|
| 45 | 45 | - A change message with `closes #N` closes that issue when it lands on the default bookmark. Any other `#N` puts the change on that issue's timeline. |
|
| 46 | 46 | ||
| 47 | + | ### Share code as a drop |
|
| 48 | + | ||
| 49 | + | A drop is a small set of files with its own history, on the hub, with an address you can pass on. |
|
| 50 | + | ||
| 51 | + | 1. Select some lines in an editor and choose **Share as a Drop…** from its right-click menu (or an editor tab's menu, for the whole file). In the Explorer, **Share Files as a Drop…** takes one or more files or a folder. |
|
| 52 | + | 2. Give it a title and choose **Secret** (unlisted: anyone with its address can read it) or **Public** (listed on your Drops page). Its address is copied for you. |
|
| 53 | + | 3. In **Drops**, click a file to open it; saving it commits a new change to the drop. A drop's menu adds or removes files, changes its title, makes it public or secret, copies its link, its `oxy clone` command or the HTML to embed it, clones it, and shows or adds comments. |
|
| 54 | + | 4. **Open a Drop…** takes someone's drop by its address, to read it, **Star** it, comment on it or **Fork** it into a drop of your own. |
|
| 55 | + | ||
| 47 | 56 | ### Publish a release |
|
| 48 | 57 | ||
| 49 | 58 | In **Releases**, run **Draft Release…** and pick a tag. The notes are drafted from the changes since the previous release; edit them before publishing. Each release lists its assets. |
|
| 59 | + | ||
| 60 | + | ### Check a release's trust |
|
| 61 | + | ||
| 62 | + | On a release in **Releases**, **Show Trust Report** shows who signed the tag and the changes, which OxygenFlow build made each asset, and the audit's verdicts. **Save Asset…** downloads an asset, with your token when the repository is private. |
|
| 63 | + | ||
| 64 | + | ### Approve, expedite and read reports in Flow |
|
| 65 | + | ||
| 66 | + | A job waiting on its approval rules shows a shield in **Flow**: **Approve Job** or **Reject Job…** decides it. A queued or running run's menu has **Expedite Run…** (say why; it goes ahead of the queue) and **Cancel Run**. A job's tooltip says how long it waited and where its time went; **Show Reports…** lists what it published. |
|
| 67 | + | ||
| 68 | + | ### Start a repository |
|
| 69 | + | ||
| 70 | + | **New Repository…** (the Proposals view's `…` menu) creates one under you or an organisation. When you say what it is for and the owner has an assistant, it can draft the starting files. Then it is cloned where you choose. |
|
| 50 | 71 | ||
| 51 | 72 | ### See what Diffusion is doing |
|
| 52 | 73 | ||
| … | 9 unchanged lines |
||
| 62 | 83 | |---|---|---| |
|
| 63 | 84 | | `oxygenhub.hubs` | `[]` | Base URLs of the hubs you use. Signing in adds to it, so you rarely edit it by hand. | |
|
| 64 | 85 | | `oxygenhub.flowPollSeconds` | `5` | How often the Flow view and its status bar item look again while a run is queued or running; once a minute otherwise. | |
|
| 86 | + | | `oxygenhub.dropVisibility` | `"secret"` | Which **Share as a Drop** offers first: `secret` or `public`. | |
|
| 87 | + | | `oxygenhub.copyDropLink` | `true` | Copy a new drop's address to the clipboard. | |
|
| 65 | 88 | | `oxygenhub.flowNotifications` | `true` | Say when an OxygenFlow run finishes, with a way to a failed job's log. | |
|
| 66 | 89 | | `oxygenhub.caFiles` | `{}` | CA certificate files for hubs with self-signed certificates, by base URL. A checkout whose remote names a CA (`remote.NAME.ca`, which `oxy` uses too) needs no entry: the extension takes it from the remote. | |
|
| 67 | 90 | | `oxygenhub.proposalBookmarkPrefix` | `"proposal/"` | Prefix of the local bookmark made by **Check Out Proposal** (`proposal/12`). | |
|
| … | 17 unchanged lines |
modified docs/spec.md +27 −3
| … | 52 unchanged lines |
||
| 53 | 53 | ||
| 54 | 54 | ## 5. Proposals |
|
| 55 | 55 | ||
| 56 | - | The **OxygenHub: Proposals** view lists, per repository that has a hub remote, proposals in three groups: needing me (open, not mine, where I have not approved or where my approval is pending a seal), mine, and other open ones. Each row shows number, title, author, target, state chips (draft, conflicted, landable) and the check summary. Actions: open detail, open on hub, check out, review (comment / request changes / approve), seal approval, land, close. |
|
| 56 | + | The **OxygenHub: Proposals** view lists, per repository that has a hub remote, proposals in three groups: needing me (open, not mine, where I have not approved or where my approval is pending a seal), mine, and other open ones. Each row shows number, title, author, target, state chips (draft, conflicted, landable) and the check summary. Actions: open detail, open on hub, check out, review (comment / request changes / approve), seal approval, land, close, and **Mark Ready for Review** on a draft or **Convert to Draft** on an open one (`PATCH …/proposals/N {"draft"}`, oxygenhub#313). |
|
| 57 | 57 | ||
| 58 | 58 | **Detail** is a webview: description, blockers, approvals (sealed or pending), checks, the changes list with signatures, and buttons for the actions. **Check out** fetches from the hub remote and sets a local bookmark `proposal/N` to the projected heads, then switches to it, so the files in the editor are the projected state; review threads then appear in place. |
|
| 59 | 59 | ||
| … | 15 unchanged lines |
||
| 75 | 75 | ||
| 76 | 76 | **Approve** posts the review, then offers to run `oxy seal --proposal <url>` in the repository's terminal so the approval is backed by the user's seal; the row shows "pending seal" until the hub reports it sealed. **Land** runs `oxy proposal land <url>` in a terminal (the hub's own landing is offered when the hub reports it can land, for repositories without a policy). Both use the integrated terminal so the user sees exactly what runs with their key. |
|
| 77 | 77 | ||
| 78 | + | **Ready to Land…** (the Proposals view's title bar) lists, across every signed-in hub, the open proposals the user could land now (`GET /user/proposals?landable=true`, oxygenhub#311): approved, checks passed, no conflicts, in repositories they write to. One in the workspace is landed with `oxy proposal land` or opened; any other opens on the hub. |
|
| 79 | + | ||
| 78 | 80 | ## 8. Issues |
|
| 79 | 81 | ||
| 80 | 82 | The **OxygenHub: Issues** view lists open issues per repository (assigned to me first). Actions: open detail (webview with the timeline and a comment box), open on hub, create issue (title and body inputs), close, reopen, assign to me. |
|
| … | 3 unchanged lines |
||
| 84 | 86 | **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 | 87 | ||
| 86 | 88 | ### 8.2 Releases |
|
| 89 | + | ||
| 90 | + | 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. **Save Asset…** (inline on an asset) downloads it into a file the user picks, with the session's token, so a private repository's assets come too; the token is sent only to the hub itself. |
|
| 87 | 91 | ||
| 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. |
|
| 92 | + | **Show Trust Report** (inline on a release) opens the hub's trust report (`GET …/releases/trust/{tag}`, oxygenhub#79) as a Markdown preview: the tag's signature and signer, the changes since the previous release, each author's signed, sealed, unsigned, revoked and invalid changes, which attested OxygenFlow build made each asset (and whether it built the tagged state), and the audit's verdicts. |
|
| 89 | 93 | ||
| 90 | 94 | **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 | 95 | ||
| 92 | 96 | ### 8.3 Diffusion |
|
| 93 | 97 | ||
| 94 | 98 | The **Diffusion** view (oxygenhub docs/diffusion.md) shows, for each repository, what it **takes from** (the subrepos it pins that are on the hub) and what it **diffuses to** (the repositories on the hub that pin it), from `GET /repos/{o}/{n}/diffusion`. Each row names the repository at the other end and reads like the hub's pill: the upstream's half, then the consumer's (`untested │ passes`), how far behind the pin is and the open bump proposal. Its icon shows the more urgent half: failing red, untested amber, waiting grey, else green; a consumer that does not follow does not count. The tooltip gives both halves' sentences, where the pin is and how the consumer follows. Picking a row opens its bump: beside the code when the bump is on this repository (the proposal detail, as the Proposals view opens it), else on the hub; a row without a bump opens the other repository. **Open on OxygenHub** (inline) opens the row's bump or repository, or a repository's Diffusion tab. Bumps are ordinary proposals, so they are also in Proposals, where they are reviewed, checked out and landed. |
|
| 99 | + | ||
| 100 | + | ### 8.4 Drops |
|
| 101 | + | ||
| 102 | + | The **Drops** view lists the user's drops on each signed-in hub (`GET /drops`; grouped by hub when there is more than one), then others' drops opened this session. A row shows the title (else the first file's name), and whether it is secret, its files, its stars and whether it is a fork; expanding it lists the files. A file opens as a document of the `oxygenhub-drop` scheme (`/ID/NAME?hub=BASE`), served by a `FileSystemProvider`: reading takes the content from `GET /drops/{id}` (text up to 512 KiB) or the file's raw address; **saving commits** the file to the drop (`PATCH /drops/{id}` with that one file, message `Edit NAME`), so each save is a change in the drop's history; deleting removes the file the same way. Renaming is refused (add it under the new name and remove the old). |
|
| 103 | + | ||
| 104 | + | **Share as a Drop…** (an editor's context menu, an editor tab's menu) shares the selection, or the whole document when nothing is selected: the selected lines, their shared indentation removed, under the file's name (an untitled editor's by its language, `snippet.py`), titled with the file and lines (`main.rs, lines 3–9`). **Share Files as a Drop…** (the Explorer's menu) shares the selected files, a folder's files (skipping `.oxy`, `.git`, `node_modules` and `target`), named by their paths below the folder they share, so two `index.ts` stay apart. Both ask for the title and **Secret** (unlisted, readable with its address) or **Public**, the one `oxygenhub.dropVisibility` names offered first, then make it (`POST /drops`) and copy its address (`oxygenhub.copyDropLink`). The hub's limits apply (50 files, 1 MiB each, text, push protection), and its refusals are shown as they are. |
|
| 105 | + | ||
| 106 | + | A drop's menu: **Add File…**, **Edit Title and Description…**, **Make Public / Secret** and **Delete Drop…** (with a modal; it and its history are gone) for the user's own; **Copy Drop Link**, **Copy oxy clone Command**, **Copy Embed HTML** (an `<iframe>` of its embed address), **Clone Drop…** (`oxy clone` in a terminal, into a folder the user picks), **Comments…** (a pick of its comments with *Add a comment…*; the comment's author or the drop's owner may delete one) and **Star** / **Unstar** for any; **Fork Drop** for others' (`POST /drops/{id}/fork`, oxygenhub#349), which opens the copy. **Open a Drop…** takes a drop's page or id; a page on a hub the user is not signed in to is read anonymously, as a secret drop's address allows. |
|
| 95 | 107 | ||
| 108 | + | ### 8.5 New repository |
|
| 109 | + | ||
| 110 | + | **New Repository…** creates a repository (`POST /repos`) on a signed-in hub under the user or one of their organisations (`GET /user/orgs`), with a name, an optional description and a visibility. When there is a description and the owner has assistants (`GET /user/starter?owner=`, oxygenhub#347), it offers to ask one for the starting files (`POST …/starter`) and follows `GET …/starter` until they are committed, failed, or about six minutes have passed (the hub finishes either way). Then it offers to clone it (`oxy clone` in a terminal) or open it on the hub. |
|
| 111 | + | ||
| 96 | 112 | ## 9. Checks and status bar |
|
| 97 | 113 | ||
| 98 | 114 | 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. |
|
| … | 1 unchanged lines |
||
| 100 | 116 | ### 9.1 Notifications |
|
| 101 | 117 | ||
| 102 | 118 | A bell in the status bar shows the unread count summed over every signed-in hub (`GET /notifications`, polled on `oxygenhub.autoRefresh`). When the count rises, a message names the newest entry (or the number of new ones) with **Open Inbox**; `oxygenhub.announceNotifications` turns that off. **Open Inbox** is a quick pick of the newest entries, unread first, with **Mark all as read** at the top. Picking one marks it read and opens it in the editor when it is a proposal or issue of a repository in the workspace (the proposal detail or the issue view), otherwise on the hub. A hub whose token lacks `user:read` is skipped and named in the bell's tooltip. |
|
| 119 | + | ||
| 120 | + | ### 9.2 Test results |
|
| 121 | + | ||
| 122 | + | A `TestController` puts the newest OxygenFlow test report of each repository's current bookmark (`GET …/tests?bookmark=&limit=1`, then `GET …/tests/{id}`) in VS Code's Testing view: the repository, then its suites, then its cases, each with its result, time, failure message and output. A case's id is the repository, suite, class and name, so the Testing view's history lines up across reports. Nothing runs locally: running a test, or refreshing, loads the hub's latest results again; refresh also follows the views' refresh. |
|
| 123 | + | ||
| 124 | + | ### 9.3 Flow |
|
| 125 | + | ||
| 126 | + | In the **Flow** view, a job waiting on its approval rules (`approval` `waiting` while queued, oxygenflow#57) shows a shield and "waiting for approval", with **Approve Job** and **Reject Job…** (a modal: the run fails) inline (`POST …/jobs/{job}/approve|reject`). A queued or running run has **Expedite Run…** (asks why; `POST …/expedite`, oxygenhub#244) and **Cancel Run**. A job's tooltip gives its timings (#348): how long it waited for a runner, each phase its runner reported, and its peak disk and memory. **Show Reports…** on a job lists the reports it published (title, summary, metrics) and opens the picked one on the hub. |
|
| 103 | 127 | ||
| 104 | 128 | ## 10. Configuration |
|
| 105 | 129 | ||
| 106 | - | `oxygenhub.hubs`: known hub base URLs (also learnt from sign in). `oxygenhub.proposalBookmarkPrefix`: `proposal/`. `oxygenhub.autoRefresh`: seconds between view refreshes (60). |
|
| 130 | + | `oxygenhub.hubs`: known hub base URLs (also learnt from sign in). `oxygenhub.proposalBookmarkPrefix`: `proposal/`. `oxygenhub.autoRefresh`: seconds between view refreshes (60). `oxygenhub.dropVisibility`: what Share as a Drop offers first, `secret` (default) or `public`. `oxygenhub.copyDropLink`: copy a new drop's address (true). |
|
| 107 | 131 | ||
| 108 | 132 | ## 11. Milestones |
|
| 109 | 133 | ||
| … | 11 unchanged lines |
modified package.json +450 −9
| … | 1 unchanged lines |
||
| 2 | 2 | "name": "oxygenhub", |
|
| 3 | 3 | "displayName": "OxygenHub VCS", |
|
| 4 | 4 | "description": "Proposals, reviews, issues and checks from an OxygenHub, in the editor. Builds on the Oxygen extension.", |
|
| 5 | - | "version": "0.12.0", |
|
| 5 | + | "version": "0.13.0", |
|
| 6 | 6 | "publisher": "oxygenhub", |
|
| 7 | 7 | "author": { |
|
| 8 | 8 | "name": "OxygenHub.io", |
|
| … | 83 unchanged lines |
||
| 92 | 92 | "type": "boolean", |
|
| 93 | 93 | "default": true, |
|
| 94 | 94 | "description": "Say when an OxygenFlow run you can see finishes, with a way to its failed job's log." |
|
| 95 | + | }, |
|
| 96 | + | "oxygenhub.dropVisibility": { |
|
| 97 | + | "type": "string", |
|
| 98 | + | "enum": [ |
|
| 99 | + | "secret", |
|
| 100 | + | "public" |
|
| 101 | + | ], |
|
| 102 | + | "default": "secret", |
|
| 103 | + | "description": "Which Share as a Drop offers first: secret (unlisted, readable with its address) or public (listed on your Drops page)." |
|
| 104 | + | }, |
|
| 105 | + | "oxygenhub.copyDropLink": { |
|
| 106 | + | "type": "boolean", |
|
| 107 | + | "default": true, |
|
| 108 | + | "description": "Copy a new drop's address to the clipboard when it is made." |
|
| 95 | 109 | } |
|
| 96 | 110 | } |
|
| 97 | 111 | }, |
|
| … | 27 unchanged lines |
||
| 125 | 139 | { |
|
| 126 | 140 | "id": "oxygenhub.diffusion", |
|
| 127 | 141 | "name": "Diffusion" |
|
| 142 | + | }, |
|
| 143 | + | { |
|
| 144 | + | "id": "oxygenhub.drops", |
|
| 145 | + | "name": "Drops" |
|
| 128 | 146 | } |
|
| 129 | 147 | ] |
|
| 130 | 148 | }, |
|
| … | 5 unchanged lines |
||
| 136 | 154 | { |
|
| 137 | 155 | "view": "oxygenhub.flow", |
|
| 138 | 156 | "contents": "OxygenFlow runs of your hub repositories appear here: a push that moves a bookmark starts one.\n[Run Pipeline](command:oxygenhub.runPipeline)" |
|
| 157 | + | }, |
|
| 158 | + | { |
|
| 159 | + | "view": "oxygenhub.drops", |
|
| 160 | + | "contents": "Drops are small shareable sets of files: snippets, notes, configs, each with its own history.\n[New Drop](command:oxygenhub.newDrop)\n[Open a Drop](command:oxygenhub.openDrop)\nSelect code in an editor and choose Share as a Drop from its menu." |
|
| 139 | 161 | } |
|
| 140 | 162 | ], |
|
| 141 | 163 | "commands": [ |
|
| … | 173 unchanged lines |
||
| 315 | 337 | { |
|
| 316 | 338 | "command": "oxygenhub.openDiffusionOnHub", |
|
| 317 | 339 | "title": "Open on OxygenHub", |
|
| 340 | + | "category": "OxygenHub", |
|
| 341 | + | "icon": "$(link-external)" |
|
| 342 | + | }, |
|
| 343 | + | { |
|
| 344 | + | "command": "oxygenhub.dropsRefresh", |
|
| 345 | + | "title": "Refresh Drops", |
|
| 346 | + | "category": "OxygenHub", |
|
| 347 | + | "icon": "$(refresh)" |
|
| 348 | + | }, |
|
| 349 | + | { |
|
| 350 | + | "command": "oxygenhub.newDrop", |
|
| 351 | + | "title": "New Drop\u2026", |
|
| 352 | + | "category": "OxygenHub", |
|
| 353 | + | "icon": "$(add)" |
|
| 354 | + | }, |
|
| 355 | + | { |
|
| 356 | + | "command": "oxygenhub.openDrop", |
|
| 357 | + | "title": "Open a Drop\u2026", |
|
| 318 | 358 | "category": "OxygenHub", |
|
| 319 | 359 | "icon": "$(link-external)" |
|
| 360 | + | }, |
|
| 361 | + | { |
|
| 362 | + | "command": "oxygenhub.shareSelectionAsDrop", |
|
| 363 | + | "title": "Share as a Drop\u2026", |
|
| 364 | + | "category": "OxygenHub", |
|
| 365 | + | "icon": "$(cloud-upload)" |
|
| 366 | + | }, |
|
| 367 | + | { |
|
| 368 | + | "command": "oxygenhub.shareFilesAsDrop", |
|
| 369 | + | "title": "Share Files as a Drop\u2026", |
|
| 370 | + | "category": "OxygenHub", |
|
| 371 | + | "icon": "$(cloud-upload)" |
|
| 372 | + | }, |
|
| 373 | + | { |
|
| 374 | + | "command": "oxygenhub.openDropOnHub", |
|
| 375 | + | "title": "Open Drop on Hub", |
|
| 376 | + | "category": "OxygenHub", |
|
| 377 | + | "icon": "$(globe)" |
|
| 378 | + | }, |
|
| 379 | + | { |
|
| 380 | + | "command": "oxygenhub.copyDropLink", |
|
| 381 | + | "title": "Copy Drop Link", |
|
| 382 | + | "category": "OxygenHub", |
|
| 383 | + | "icon": "$(link)" |
|
| 384 | + | }, |
|
| 385 | + | { |
|
| 386 | + | "command": "oxygenhub.copyDropClone", |
|
| 387 | + | "title": "Copy oxy clone Command", |
|
| 388 | + | "category": "OxygenHub" |
|
| 389 | + | }, |
|
| 390 | + | { |
|
| 391 | + | "command": "oxygenhub.copyDropEmbed", |
|
| 392 | + | "title": "Copy Embed HTML", |
|
| 393 | + | "category": "OxygenHub" |
|
| 394 | + | }, |
|
| 395 | + | { |
|
| 396 | + | "command": "oxygenhub.cloneDrop", |
|
| 397 | + | "title": "Clone Drop\u2026", |
|
| 398 | + | "category": "OxygenHub" |
|
| 399 | + | }, |
|
| 400 | + | { |
|
| 401 | + | "command": "oxygenhub.editDrop", |
|
| 402 | + | "title": "Edit Title and Description\u2026", |
|
| 403 | + | "category": "OxygenHub", |
|
| 404 | + | "icon": "$(edit)" |
|
| 405 | + | }, |
|
| 406 | + | { |
|
| 407 | + | "command": "oxygenhub.toggleDropVisibility", |
|
| 408 | + | "title": "Make Public / Secret", |
|
| 409 | + | "category": "OxygenHub" |
|
| 410 | + | }, |
|
| 411 | + | { |
|
| 412 | + | "command": "oxygenhub.addDropFile", |
|
| 413 | + | "title": "Add File\u2026", |
|
| 414 | + | "category": "OxygenHub", |
|
| 415 | + | "icon": "$(new-file)" |
|
| 416 | + | }, |
|
| 417 | + | { |
|
| 418 | + | "command": "oxygenhub.deleteDropFile", |
|
| 419 | + | "title": "Remove File from Drop", |
|
| 420 | + | "category": "OxygenHub" |
|
| 421 | + | }, |
|
| 422 | + | { |
|
| 423 | + | "command": "oxygenhub.deleteDrop", |
|
| 424 | + | "title": "Delete Drop\u2026", |
|
| 425 | + | "category": "OxygenHub", |
|
| 426 | + | "icon": "$(trash)" |
|
| 427 | + | }, |
|
| 428 | + | { |
|
| 429 | + | "command": "oxygenhub.forkDrop", |
|
| 430 | + | "title": "Fork Drop", |
|
| 431 | + | "category": "OxygenHub", |
|
| 432 | + | "icon": "$(repo-forked)" |
|
| 433 | + | }, |
|
| 434 | + | { |
|
| 435 | + | "command": "oxygenhub.starDrop", |
|
| 436 | + | "title": "Star Drop", |
|
| 437 | + | "category": "OxygenHub", |
|
| 438 | + | "icon": "$(star-empty)" |
|
| 439 | + | }, |
|
| 440 | + | { |
|
| 441 | + | "command": "oxygenhub.unstarDrop", |
|
| 442 | + | "title": "Unstar Drop", |
|
| 443 | + | "category": "OxygenHub", |
|
| 444 | + | "icon": "$(star-full)" |
|
| 445 | + | }, |
|
| 446 | + | { |
|
| 447 | + | "command": "oxygenhub.dropComments", |
|
| 448 | + | "title": "Comments\u2026", |
|
| 449 | + | "category": "OxygenHub", |
|
| 450 | + | "icon": "$(comment-discussion)" |
|
| 451 | + | }, |
|
| 452 | + | { |
|
| 453 | + | "command": "oxygenhub.approveJob", |
|
| 454 | + | "title": "Approve Job", |
|
| 455 | + | "category": "OxygenFlow", |
|
| 456 | + | "icon": "$(pass)" |
|
| 457 | + | }, |
|
| 458 | + | { |
|
| 459 | + | "command": "oxygenhub.rejectJob", |
|
| 460 | + | "title": "Reject Job\u2026", |
|
| 461 | + | "category": "OxygenFlow", |
|
| 462 | + | "icon": "$(close)" |
|
| 463 | + | }, |
|
| 464 | + | { |
|
| 465 | + | "command": "oxygenhub.expediteRun", |
|
| 466 | + | "title": "Expedite Run\u2026", |
|
| 467 | + | "category": "OxygenFlow", |
|
| 468 | + | "icon": "$(rocket)" |
|
| 469 | + | }, |
|
| 470 | + | { |
|
| 471 | + | "command": "oxygenhub.cancelRun", |
|
| 472 | + | "title": "Cancel Run", |
|
| 473 | + | "category": "OxygenFlow", |
|
| 474 | + | "icon": "$(debug-stop)" |
|
| 475 | + | }, |
|
| 476 | + | { |
|
| 477 | + | "command": "oxygenhub.jobReports", |
|
| 478 | + | "title": "Show Reports\u2026", |
|
| 479 | + | "category": "OxygenFlow", |
|
| 480 | + | "icon": "$(graph)" |
|
| 481 | + | }, |
|
| 482 | + | { |
|
| 483 | + | "command": "oxygenhub.releaseTrust", |
|
| 484 | + | "title": "Show Trust Report", |
|
| 485 | + | "category": "OxygenHub", |
|
| 486 | + | "icon": "$(shield)" |
|
| 487 | + | }, |
|
| 488 | + | { |
|
| 489 | + | "command": "oxygenhub.saveAsset", |
|
| 490 | + | "title": "Save Asset\u2026", |
|
| 491 | + | "category": "OxygenHub", |
|
| 492 | + | "icon": "$(cloud-download)" |
|
| 493 | + | }, |
|
| 494 | + | { |
|
| 495 | + | "command": "oxygenhub.newRepository", |
|
| 496 | + | "title": "New Repository\u2026", |
|
| 497 | + | "category": "OxygenHub", |
|
| 498 | + | "icon": "$(repo-create)" |
|
| 499 | + | }, |
|
| 500 | + | { |
|
| 501 | + | "command": "oxygenhub.markReady", |
|
| 502 | + | "title": "Mark Ready for Review", |
|
| 503 | + | "category": "OxygenHub" |
|
| 504 | + | }, |
|
| 505 | + | { |
|
| 506 | + | "command": "oxygenhub.convertToDraft", |
|
| 507 | + | "title": "Convert to Draft", |
|
| 508 | + | "category": "OxygenHub" |
|
| 509 | + | }, |
|
| 510 | + | { |
|
| 511 | + | "command": "oxygenhub.landable", |
|
| 512 | + | "title": "Ready to Land\u2026", |
|
| 513 | + | "category": "OxygenHub", |
|
| 514 | + | "icon": "$(pass)" |
|
| 320 | 515 | } |
|
| 321 | 516 | ], |
|
| 322 | 517 | "menus": { |
|
| … | 26 unchanged lines |
||
| 349 | 544 | { |
|
| 350 | 545 | "command": "oxygenhub.flowRefresh", |
|
| 351 | 546 | "when": "view == oxygenhub.flow", |
|
| 547 | + | "group": "navigation@2" |
|
| 548 | + | }, |
|
| 549 | + | { |
|
| 550 | + | "command": "oxygenhub.newDrop", |
|
| 551 | + | "when": "view == oxygenhub.drops", |
|
| 552 | + | "group": "navigation@1" |
|
| 553 | + | }, |
|
| 554 | + | { |
|
| 555 | + | "command": "oxygenhub.openDrop", |
|
| 556 | + | "when": "view == oxygenhub.drops", |
|
| 352 | 557 | "group": "navigation@2" |
|
| 558 | + | }, |
|
| 559 | + | { |
|
| 560 | + | "command": "oxygenhub.dropsRefresh", |
|
| 561 | + | "when": "view == oxygenhub.drops", |
|
| 562 | + | "group": "navigation@3" |
|
| 563 | + | }, |
|
| 564 | + | { |
|
| 565 | + | "command": "oxygenhub.landable", |
|
| 566 | + | "when": "view == oxygenhub.proposals", |
|
| 567 | + | "group": "navigation" |
|
| 568 | + | }, |
|
| 569 | + | { |
|
| 570 | + | "command": "oxygenhub.newRepository", |
|
| 571 | + | "when": "view == oxygenhub.proposals", |
|
| 572 | + | "group": "9_new" |
|
| 353 | 573 | } |
|
| 354 | 574 | ], |
|
| 355 | 575 | "view/item/context": [ |
|
| 356 | 576 | { |
|
| 357 | 577 | "command": "oxygenhub.openProposalChanges", |
|
| 358 | - | "when": "view == oxygenhub.proposals && viewItem == proposal", |
|
| 578 | + | "when": "view == oxygenhub.proposals && viewItem =~ /^proposal(Draft)?$/", |
|
| 359 | 579 | "group": "inline" |
|
| 360 | 580 | }, |
|
| 361 | 581 | { |
|
| 362 | 582 | "command": "oxygenhub.openOnHub", |
|
| 363 | - | "when": "view == oxygenhub.proposals && viewItem == proposal", |
|
| 583 | + | "when": "view == oxygenhub.proposals && viewItem =~ /^proposal(Draft)?$/", |
|
| 364 | 584 | "group": "inline" |
|
| 365 | 585 | }, |
|
| 366 | 586 | { |
|
| 367 | 587 | "command": "oxygenhub.checkoutProposal", |
|
| 368 | - | "when": "view == oxygenhub.proposals && viewItem == proposal", |
|
| 588 | + | "when": "view == oxygenhub.proposals && viewItem =~ /^proposal(Draft)?$/", |
|
| 369 | 589 | "group": "inline" |
|
| 370 | 590 | }, |
|
| 371 | 591 | { |
|
| 372 | 592 | "command": "oxygenhub.review", |
|
| 373 | - | "when": "view == oxygenhub.proposals && viewItem == proposal", |
|
| 593 | + | "when": "view == oxygenhub.proposals && viewItem =~ /^proposal(Draft)?$/", |
|
| 374 | 594 | "group": "inline" |
|
| 375 | 595 | }, |
|
| 376 | 596 | { |
|
| 377 | 597 | "command": "oxygenhub.sealApproval", |
|
| 378 | - | "when": "view == oxygenhub.proposals && viewItem == proposal", |
|
| 598 | + | "when": "view == oxygenhub.proposals && viewItem =~ /^proposal(Draft)?$/", |
|
| 379 | 599 | "group": "actions@1" |
|
| 380 | 600 | }, |
|
| 381 | 601 | { |
|
| 382 | 602 | "command": "oxygenhub.land", |
|
| 383 | - | "when": "view == oxygenhub.proposals && viewItem == proposal", |
|
| 603 | + | "when": "view == oxygenhub.proposals && viewItem =~ /^proposal(Draft)?$/", |
|
| 384 | 604 | "group": "actions@2" |
|
| 385 | 605 | }, |
|
| 386 | 606 | { |
|
| 387 | 607 | "command": "oxygenhub.closeProposal", |
|
| 388 | - | "when": "view == oxygenhub.proposals && viewItem == proposal", |
|
| 608 | + | "when": "view == oxygenhub.proposals && viewItem =~ /^proposal(Draft)?$/", |
|
| 389 | 609 | "group": "actions@3" |
|
| 390 | 610 | }, |
|
| 391 | 611 | { |
|
| … | 48 unchanged lines |
||
| 440 | 660 | }, |
|
| 441 | 661 | { |
|
| 442 | 662 | "command": "oxygenhub.flowLog", |
|
| 663 | + | "when": "view == oxygenhub.flow && viewItem == flowJob", |
|
| 664 | + | "group": "inline" |
|
| 665 | + | }, |
|
| 666 | + | { |
|
| 667 | + | "command": "oxygenhub.copyDropLink", |
|
| 668 | + | "when": "view == oxygenhub.drops && viewItem =~ /^drop(Mine|Other)/", |
|
| 669 | + | "group": "inline@1" |
|
| 670 | + | }, |
|
| 671 | + | { |
|
| 672 | + | "command": "oxygenhub.openDropOnHub", |
|
| 673 | + | "when": "view == oxygenhub.drops && viewItem =~ /^drop(Mine|Other)/", |
|
| 674 | + | "group": "inline@2" |
|
| 675 | + | }, |
|
| 676 | + | { |
|
| 677 | + | "command": "oxygenhub.addDropFile", |
|
| 678 | + | "when": "view == oxygenhub.drops && viewItem =~ /^dropMine/", |
|
| 679 | + | "group": "1_edit@1" |
|
| 680 | + | }, |
|
| 681 | + | { |
|
| 682 | + | "command": "oxygenhub.editDrop", |
|
| 683 | + | "when": "view == oxygenhub.drops && viewItem =~ /^dropMine/", |
|
| 684 | + | "group": "1_edit@2" |
|
| 685 | + | }, |
|
| 686 | + | { |
|
| 687 | + | "command": "oxygenhub.toggleDropVisibility", |
|
| 688 | + | "when": "view == oxygenhub.drops && viewItem =~ /^dropMine/", |
|
| 689 | + | "group": "1_edit@3" |
|
| 690 | + | }, |
|
| 691 | + | { |
|
| 692 | + | "command": "oxygenhub.copyDropLink", |
|
| 693 | + | "when": "view == oxygenhub.drops && viewItem =~ /^drop(Mine|Other)/", |
|
| 694 | + | "group": "2_share@1" |
|
| 695 | + | }, |
|
| 696 | + | { |
|
| 697 | + | "command": "oxygenhub.copyDropClone", |
|
| 698 | + | "when": "view == oxygenhub.drops && viewItem =~ /^drop(Mine|Other)/", |
|
| 699 | + | "group": "2_share@2" |
|
| 700 | + | }, |
|
| 701 | + | { |
|
| 702 | + | "command": "oxygenhub.copyDropEmbed", |
|
| 703 | + | "when": "view == oxygenhub.drops && viewItem =~ /^drop(Mine|Other)/", |
|
| 704 | + | "group": "2_share@3" |
|
| 705 | + | }, |
|
| 706 | + | { |
|
| 707 | + | "command": "oxygenhub.cloneDrop", |
|
| 708 | + | "when": "view == oxygenhub.drops && viewItem =~ /^drop(Mine|Other)/", |
|
| 709 | + | "group": "2_share@4" |
|
| 710 | + | }, |
|
| 711 | + | { |
|
| 712 | + | "command": "oxygenhub.dropComments", |
|
| 713 | + | "when": "view == oxygenhub.drops && viewItem =~ /^drop(Mine|Other)/", |
|
| 714 | + | "group": "3_social@1" |
|
| 715 | + | }, |
|
| 716 | + | { |
|
| 717 | + | "command": "oxygenhub.starDrop", |
|
| 718 | + | "when": "view == oxygenhub.drops && viewItem =~ /^drop(Mine|Other)/ && viewItem =~ /^drop(Mine|Other)$/", |
|
| 719 | + | "group": "3_social@2" |
|
| 720 | + | }, |
|
| 721 | + | { |
|
| 722 | + | "command": "oxygenhub.unstarDrop", |
|
| 723 | + | "when": "view == oxygenhub.drops && viewItem =~ /Starred$/", |
|
| 724 | + | "group": "3_social@2" |
|
| 725 | + | }, |
|
| 726 | + | { |
|
| 727 | + | "command": "oxygenhub.forkDrop", |
|
| 728 | + | "when": "view == oxygenhub.drops && viewItem =~ /^dropOther/", |
|
| 729 | + | "group": "3_social@3" |
|
| 730 | + | }, |
|
| 731 | + | { |
|
| 732 | + | "command": "oxygenhub.deleteDrop", |
|
| 733 | + | "when": "view == oxygenhub.drops && viewItem =~ /^dropMine/", |
|
| 734 | + | "group": "9_delete" |
|
| 735 | + | }, |
|
| 736 | + | { |
|
| 737 | + | "command": "oxygenhub.deleteDropFile", |
|
| 738 | + | "when": "view == oxygenhub.drops && viewItem == dropFileMine", |
|
| 739 | + | "group": "9_delete" |
|
| 740 | + | }, |
|
| 741 | + | { |
|
| 742 | + | "command": "oxygenhub.markReady", |
|
| 743 | + | "when": "view == oxygenhub.proposals && viewItem == proposalDraft", |
|
| 744 | + | "group": "actions@0" |
|
| 745 | + | }, |
|
| 746 | + | { |
|
| 747 | + | "command": "oxygenhub.convertToDraft", |
|
| 748 | + | "when": "view == oxygenhub.proposals && viewItem == proposal", |
|
| 749 | + | "group": "actions@4" |
|
| 750 | + | }, |
|
| 751 | + | { |
|
| 752 | + | "command": "oxygenhub.approveJob", |
|
| 753 | + | "when": "view == oxygenhub.flow && viewItem == flowJobWaiting", |
|
| 754 | + | "group": "inline@1" |
|
| 755 | + | }, |
|
| 756 | + | { |
|
| 757 | + | "command": "oxygenhub.rejectJob", |
|
| 758 | + | "when": "view == oxygenhub.flow && viewItem == flowJobWaiting", |
|
| 759 | + | "group": "inline@2" |
|
| 760 | + | }, |
|
| 761 | + | { |
|
| 762 | + | "command": "oxygenhub.jobReports", |
|
| 443 | 763 | "when": "view == oxygenhub.flow && viewItem == flowJob", |
|
| 764 | + | "group": "actions@1" |
|
| 765 | + | }, |
|
| 766 | + | { |
|
| 767 | + | "command": "oxygenhub.expediteRun", |
|
| 768 | + | "when": "view == oxygenhub.flow && viewItem == flowRunLive", |
|
| 769 | + | "group": "actions@1" |
|
| 770 | + | }, |
|
| 771 | + | { |
|
| 772 | + | "command": "oxygenhub.cancelRun", |
|
| 773 | + | "when": "view == oxygenhub.flow && viewItem == flowRunLive", |
|
| 774 | + | "group": "actions@2" |
|
| 775 | + | }, |
|
| 776 | + | { |
|
| 777 | + | "command": "oxygenhub.releaseTrust", |
|
| 778 | + | "when": "view == oxygenhub.releases && viewItem == release", |
|
| 779 | + | "group": "inline" |
|
| 780 | + | }, |
|
| 781 | + | { |
|
| 782 | + | "command": "oxygenhub.saveAsset", |
|
| 783 | + | "when": "view == oxygenhub.releases && viewItem == releaseAsset", |
|
| 444 | 784 | "group": "inline" |
|
| 445 | 785 | } |
|
| 446 | 786 | ], |
|
| … | 39 unchanged lines |
||
| 486 | 826 | }, |
|
| 487 | 827 | { |
|
| 488 | 828 | "command": "oxygenhub.openFlowRun", |
|
| 829 | + | "when": "false" |
|
| 830 | + | }, |
|
| 831 | + | { |
|
| 832 | + | "command": "oxygenhub.openDropOnHub", |
|
| 833 | + | "when": "false" |
|
| 834 | + | }, |
|
| 835 | + | { |
|
| 836 | + | "command": "oxygenhub.copyDropLink", |
|
| 837 | + | "when": "false" |
|
| 838 | + | }, |
|
| 839 | + | { |
|
| 840 | + | "command": "oxygenhub.copyDropClone", |
|
| 489 | 841 | "when": "false" |
|
| 842 | + | }, |
|
| 843 | + | { |
|
| 844 | + | "command": "oxygenhub.copyDropEmbed", |
|
| 845 | + | "when": "false" |
|
| 846 | + | }, |
|
| 847 | + | { |
|
| 848 | + | "command": "oxygenhub.cloneDrop", |
|
| 849 | + | "when": "false" |
|
| 850 | + | }, |
|
| 851 | + | { |
|
| 852 | + | "command": "oxygenhub.editDrop", |
|
| 853 | + | "when": "false" |
|
| 854 | + | }, |
|
| 855 | + | { |
|
| 856 | + | "command": "oxygenhub.toggleDropVisibility", |
|
| 857 | + | "when": "false" |
|
| 858 | + | }, |
|
| 859 | + | { |
|
| 860 | + | "command": "oxygenhub.addDropFile", |
|
| 861 | + | "when": "false" |
|
| 862 | + | }, |
|
| 863 | + | { |
|
| 864 | + | "command": "oxygenhub.deleteDropFile", |
|
| 865 | + | "when": "false" |
|
| 866 | + | }, |
|
| 867 | + | { |
|
| 868 | + | "command": "oxygenhub.deleteDrop", |
|
| 869 | + | "when": "false" |
|
| 870 | + | }, |
|
| 871 | + | { |
|
| 872 | + | "command": "oxygenhub.forkDrop", |
|
| 873 | + | "when": "false" |
|
| 874 | + | }, |
|
| 875 | + | { |
|
| 876 | + | "command": "oxygenhub.starDrop", |
|
| 877 | + | "when": "false" |
|
| 878 | + | }, |
|
| 879 | + | { |
|
| 880 | + | "command": "oxygenhub.unstarDrop", |
|
| 881 | + | "when": "false" |
|
| 882 | + | }, |
|
| 883 | + | { |
|
| 884 | + | "command": "oxygenhub.dropComments", |
|
| 885 | + | "when": "false" |
|
| 886 | + | }, |
|
| 887 | + | { |
|
| 888 | + | "command": "oxygenhub.approveJob", |
|
| 889 | + | "when": "false" |
|
| 890 | + | }, |
|
| 891 | + | { |
|
| 892 | + | "command": "oxygenhub.rejectJob", |
|
| 893 | + | "when": "false" |
|
| 894 | + | }, |
|
| 895 | + | { |
|
| 896 | + | "command": "oxygenhub.expediteRun", |
|
| 897 | + | "when": "false" |
|
| 898 | + | }, |
|
| 899 | + | { |
|
| 900 | + | "command": "oxygenhub.cancelRun", |
|
| 901 | + | "when": "false" |
|
| 902 | + | }, |
|
| 903 | + | { |
|
| 904 | + | "command": "oxygenhub.jobReports", |
|
| 905 | + | "when": "false" |
|
| 906 | + | }, |
|
| 907 | + | { |
|
| 908 | + | "command": "oxygenhub.releaseTrust", |
|
| 909 | + | "when": "false" |
|
| 910 | + | }, |
|
| 911 | + | { |
|
| 912 | + | "command": "oxygenhub.saveAsset", |
|
| 913 | + | "when": "false" |
|
| 490 | 914 | } |
|
| 491 | 915 | ], |
|
| 492 | 916 | "editor/context": [ |
|
| … | 1 unchanged lines |
||
| 494 | 918 | "command": "oxygenhub.issueFromSelection", |
|
| 495 | 919 | "when": "resourceScheme == file", |
|
| 496 | 920 | "group": "9_oxygenhub@1" |
|
| 921 | + | }, |
|
| 922 | + | { |
|
| 923 | + | "command": "oxygenhub.shareSelectionAsDrop", |
|
| 924 | + | "group": "9_oxygenhub@2" |
|
| 925 | + | } |
|
| 926 | + | ], |
|
| 927 | + | "editor/title/context": [ |
|
| 928 | + | { |
|
| 929 | + | "command": "oxygenhub.shareSelectionAsDrop", |
|
| 930 | + | "when": "resourceScheme == file || resourceScheme == untitled", |
|
| 931 | + | "group": "9_oxygenhub" |
|
| 932 | + | } |
|
| 933 | + | ], |
|
| 934 | + | "explorer/context": [ |
|
| 935 | + | { |
|
| 936 | + | "command": "oxygenhub.shareFilesAsDrop", |
|
| 937 | + | "group": "9_oxygenhub" |
|
| 497 | 938 | } |
|
| 498 | 939 | ] |
|
| 499 | 940 | }, |
|
| … | 79 unchanged lines |
||
| 579 | 1020 | "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --external:vscode --outfile=dist/extension.js --sourcemap", |
|
| 580 | 1021 | "watch": "npm run build -- --watch", |
|
| 581 | 1022 | "typecheck": "tsc --noEmit -p .", |
|
| 582 | - | "pretest": "esbuild src/hub.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/hub.mjs && esbuild src/provider.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/provider.mjs && esbuild src/refs.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/refs.mjs && esbuild src/flowview.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/flowview.mjs && esbuild src/diffusionview.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/diffusionview.mjs", |
|
| 1023 | + | "pretest": "esbuild src/hub.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/hub.mjs && esbuild src/provider.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/provider.mjs && esbuild src/refs.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/refs.mjs && esbuild src/flowview.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/flowview.mjs && esbuild src/diffusionview.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/diffusionview.mjs && esbuild src/dropkit.ts --bundle --platform=node --format=esm --sourcemap --outfile=out/dropkit.mjs", |
|
| 583 | 1024 | "test": "node --enable-source-maps --test \"test/**/*.test.mjs\"", |
|
| 584 | 1025 | "test:ci": "node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude=\"test/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=out/junit.xml --test-reporter=lcov --test-reporter-destination=out/lcov.info \"test/**/*.test.mjs\"", |
|
| 585 | 1026 | "check": "npm run typecheck && npm run build && npm test", |
|
| … | 10 unchanged lines |
modified src/extension.ts +77 −1
| … | 7 unchanged lines |
||
| 8 | 8 | import { FlowLogs, FlowProvider, JobNode, LOG_SCHEME, RunNode, openLog, useLogs } from "./flow"; |
|
| 9 | 9 | import { hubUrl } from "./hub"; |
|
| 10 | 10 | import { InboxBar } from "./inbox"; |
|
| 11 | - | import { draftRelease, ReleasesProvider } from "./releases"; |
|
| 11 | + | import { AssetNode, draftRelease, ReleaseNode, ReleasesProvider, saveAsset, showTrust } from "./releases"; |
|
| 12 | + | import { DROP_SCHEME } from "./dropkit"; |
|
| 13 | + | import { DropFileSystem, DropsProvider, registerDropCommands, shareFiles, shareSelection } from "./drops"; |
|
| 14 | + | import { newRepository } from "./newrepo"; |
|
| 15 | + | import { HubTests } from "./tests"; |
|
| 12 | 16 | import { DiffusionPinNode, DiffusionProvider, diffusionTab, pinUrl } from "./diffusion"; |
|
| 13 | 17 | import { ReferenceCompletions, ReferenceHovers, ReferenceIndex, SELECTOR } from "./references"; |
|
| 14 | 18 | import { annotateTodo, issueBody, todoTitle } from "./refs"; |
|
| … | 15 unchanged lines |
||
| 30 | 34 | let releases: ReleasesProvider; |
|
| 31 | 35 | let diffusion: DiffusionProvider; |
|
| 32 | 36 | let flow: FlowProvider; |
|
| 37 | + | let drops: DropsProvider; |
|
| 38 | + | let tests: HubTests; |
|
| 33 | 39 | ||
| 34 | 40 | export async function activate(context: vscode.ExtensionContext): Promise<void> { |
|
| 35 | 41 | auth = new HubAuth(context.secrets); |
|
| … | 75 unchanged lines |
||
| 111 | 117 | vscode.commands.registerCommand("oxygenhub.flowLog", (node: JobNode) => openLog(node.repo, node.run, node.job)), |
|
| 112 | 118 | vscode.commands.registerCommand("oxygenhub.openFlowRun", (node: RunNode) => vscode.env.openExternal(vscode.Uri.parse(node.run.url))), |
|
| 113 | 119 | vscode.commands.registerCommand("oxygenhub.flowRecent", (repo?: HubRepo) => flow.recent(repo)), |
|
| 120 | + | vscode.commands.registerCommand("oxygenhub.approveJob", (node: JobNode) => flow.decide(node, true)), |
|
| 121 | + | vscode.commands.registerCommand("oxygenhub.rejectJob", (node: JobNode) => flow.decide(node, false)), |
|
| 122 | + | vscode.commands.registerCommand("oxygenhub.expediteRun", (node: RunNode) => flow.expedite(node)), |
|
| 123 | + | vscode.commands.registerCommand("oxygenhub.cancelRun", (node: RunNode) => flow.cancel(node)), |
|
| 124 | + | vscode.commands.registerCommand("oxygenhub.jobReports", (node: JobNode) => flow.reports(node)), |
|
| 114 | 125 | ); |
|
| 126 | + | drops = new DropsProvider(auth); |
|
| 127 | + | context.subscriptions.push( |
|
| 128 | + | vscode.window.registerTreeDataProvider("oxygenhub.drops", drops), |
|
| 129 | + | vscode.workspace.registerFileSystemProvider(DROP_SCHEME, new DropFileSystem(auth, () => drops.refresh()), { isCaseSensitive: true }), |
|
| 130 | + | ...registerDropCommands(auth, drops), |
|
| 131 | + | vscode.commands.registerCommand("oxygenhub.shareSelectionAsDrop", (uri?: vscode.Uri) => shareSelection(auth, drops, uri instanceof vscode.Uri ? uri : undefined)), |
|
| 132 | + | vscode.commands.registerCommand("oxygenhub.shareFilesAsDrop", (uri?: vscode.Uri, uris?: vscode.Uri[]) => shareFiles(auth, drops, uri instanceof vscode.Uri ? uri : undefined, uris)), |
|
| 133 | + | ); |
|
| 134 | + | tests = new HubTests(auth, () => proposals.repos()); |
|
| 135 | + | context.subscriptions.push( |
|
| 136 | + | tests, |
|
| 137 | + | vscode.commands.registerCommand("oxygenhub.releaseTrust", (node: ReleaseNode) => showTrust(auth, node).catch((e: Error) => vscode.window.showErrorMessage(`OxygenHub: ${e.message}`))), |
|
| 138 | + | vscode.commands.registerCommand("oxygenhub.saveAsset", (node: AssetNode) => saveAsset(auth, node).catch((e: Error) => vscode.window.showErrorMessage(`OxygenHub: ${e.message}`))), |
|
| 139 | + | vscode.commands.registerCommand("oxygenhub.newRepository", () => newRepository(auth)), |
|
| 140 | + | vscode.commands.registerCommand("oxygenhub.markReady", (...args: unknown[]) => withProposal(args, (repo, number) => setDraft(repo, number, false))), |
|
| 141 | + | vscode.commands.registerCommand("oxygenhub.convertToDraft", (...args: unknown[]) => withProposal(args, (repo, number) => setDraft(repo, number, true))), |
|
| 142 | + | vscode.commands.registerCommand("oxygenhub.landable", () => landable()), |
|
| 143 | + | ); |
|
| 115 | 144 | references = new ReferenceIndex(auth, () => proposals.repos()); |
|
| 116 | 145 | context.subscriptions.push( |
|
| 117 | 146 | vscode.languages.registerCompletionItemProvider(SELECTOR, new ReferenceCompletions(references), "#", "@"), |
|
| … | 106 unchanged lines |
||
| 224 | 253 | ); |
|
| 225 | 254 | bar.start(); |
|
| 226 | 255 | inbox.start(); |
|
| 256 | + | void tests.load(); |
|
| 227 | 257 | } |
|
| 228 | 258 | ||
| 229 | 259 | function refreshAll(): void { |
|
| … | 3 unchanged lines |
||
| 233 | 263 | issues.refresh(); |
|
| 234 | 264 | releases.refresh(); |
|
| 235 | 265 | diffusion.refresh(); |
|
| 266 | + | drops.refresh(); |
|
| 267 | + | void tests.load(); |
|
| 236 | 268 | void flow.refresh(); |
|
| 237 | 269 | void bar.refresh(); |
|
| 238 | 270 | void inbox.refresh(); |
|
| … | 229 unchanged lines |
||
| 468 | 500 | } |
|
| 469 | 501 | } |
|
| 470 | 502 | await showIssue(auth, repo, issue); |
|
| 503 | + | } catch (e) { |
|
| 504 | + | void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); |
|
| 505 | + | } |
|
| 506 | + | } |
|
| 507 | + | ||
| 508 | + | /** Mark a draft ready for review, or turn an open proposal back into a draft. */ |
|
| 509 | + | async function setDraft(repo: HubRepo, number: number, draft: boolean): Promise<void> { |
|
| 510 | + | try { |
|
| 511 | + | await auth.clientFor(repo.base).setProposalDraft(repo.owner, repo.name, number, draft); |
|
| 512 | + | proposals.refresh(); |
|
| 513 | + | vscode.window.setStatusBarMessage(draft ? `$(circle-outline) #${number} is a draft again` : `$(git-pull-request) #${number} is ready for review`, 4000); |
|
| 471 | 514 | } catch (e) { |
|
| 472 | 515 | void vscode.window.showErrorMessage(`OxygenHub: ${(e as Error).message}`); |
|
| 516 | + | } |
|
| 517 | + | } |
|
| 518 | + | ||
| 519 | + | /** What you can land now, across every repository you write to (#311). */ |
|
| 520 | + | async function landable(): Promise<void> { |
|
| 521 | + | const repos = await proposals.repos(); |
|
| 522 | + | type Item = vscode.QuickPickItem & { url: string; repo?: HubRepo; number: number }; |
|
| 523 | + | const items: Item[] = []; |
|
| 524 | + | for (const base of auth.all().map((x) => x.base)) { |
|
| 525 | + | const list = await auth.clientFor(base).userProposals(true).catch(() => undefined); |
|
| 526 | + | for (const p of list?.landable ?? []) { |
|
| 527 | + | const [owner, name] = p.repository.split("/"); |
|
| 528 | + | const repo = repos.find((r) => r.base === base && r.owner === owner && r.name === name); |
|
| 529 | + | items.push({ label: `$(pass) #${p.number} ${p.title}`, description: `${p.repository} · ${p.author}`, detail: repo ? undefined : "not in this workspace: opens on the hub", url: p.url, repo, number: p.number }); |
|
| 530 | + | } |
|
| 531 | + | } |
|
| 532 | + | if (!items.length) { |
|
| 533 | + | void vscode.window.showInformationMessage("OxygenHub: nothing is ready for you to land."); |
|
| 534 | + | return; |
|
| 535 | + | } |
|
| 536 | + | const pick = await vscode.window.showQuickPick(items, { placeHolder: "Ready to land: approved, checks passed, no conflicts" }); |
|
| 537 | + | if (!pick) { |
|
| 538 | + | return; |
|
| 539 | + | } |
|
| 540 | + | if (!pick.repo) { |
|
| 541 | + | void vscode.env.openExternal(vscode.Uri.parse(pick.url)); |
|
| 542 | + | return; |
|
| 543 | + | } |
|
| 544 | + | const act = await vscode.window.showQuickPick(["Land", "Open"], { placeHolder: `#${pick.number}` }); |
|
| 545 | + | if (act === "Land") { |
|
| 546 | + | await runInTerminal(pick.repo, `oxy proposal land ${pick.url}`); |
|
| 547 | + | } else if (act === "Open") { |
|
| 548 | + | await vscode.commands.executeCommand("oxygenhub.openProposal", { kind: "proposal", repo: pick.repo, p: await auth.clientFor(pick.repo.base).proposal(pick.repo.owner, pick.repo.name, pick.number) }); |
|
| 473 | 549 | } |
|
| 474 | 550 | } |
|
| 475 | 551 | ||
| … | 1 unchanged lines |
modified src/flow.ts +80 −3
| … | 6 unchanged lines |
||
| 7 | 7 | import type * as api from "./hubapi"; |
|
| 8 | 8 | import { HubAuth } from "./auth"; |
|
| 9 | 9 | import type { HubRepo } from "./proposals"; |
|
| 10 | - | import { duration, live, look, newlyFinished, runDescription, runLabel, subject } from "./flowview"; |
|
| 10 | + | import { duration, jobTimings, live, look, newlyFinished, runDescription, runLabel, subject, waitingApproval } from "./flowview"; |
|
| 11 | 11 | ||
| 12 | 12 | export const LOG_SCHEME = "oxygenhub-flow"; |
|
| 13 | 13 | ||
| … | 155 unchanged lines |
||
| 169 | 169 | const took = j.started_ms ? duration((j.finished_ms ?? Date.now()) - j.started_ms) : ""; |
|
| 170 | 170 | item.description = [look(j.status).word, took, j.runner ? `on ${j.runner}` : ""].filter(Boolean).join(" · "); |
|
| 171 | 171 | item.iconPath = icon(j.status); |
|
| 172 | - | item.contextValue = "flowJob"; |
|
| 172 | + | const waiting = waitingApproval(j); |
|
| 173 | + | if (waiting) { |
|
| 174 | + | item.description = "waiting for approval"; |
|
| 175 | + | item.iconPath = new vscode.ThemeIcon("shield", new vscode.ThemeColor("testing.iconQueued")); |
|
| 176 | + | } |
|
| 177 | + | item.contextValue = waiting ? "flowJobWaiting" : "flowJob"; |
|
| 173 | 178 | item.command = { title: "Show Log", command: "oxygenhub.flowLog", arguments: [node] }; |
|
| 174 | - | item.tooltip = j.error ? new vscode.MarkdownString(`**${j.name}**: ${j.error}`) : `${j.name} — ${look(j.status).word}; click for its log`; |
|
| 179 | + | const timings = jobTimings(j); |
|
| 180 | + | const md = new vscode.MarkdownString(`**${j.name}** — ${waiting ? "waiting for approval" : look(j.status).word}${j.error ? `: ${j.error}` : ""}`); |
|
| 181 | + | if (timings.length) { |
|
| 182 | + | md.appendMarkdown(`\n\n${timings.join(" · ")}`); |
|
| 183 | + | } |
|
| 184 | + | md.appendMarkdown(waiting ? "\n\nApprove or reject it from its menu." : "\n\nClick for its log."); |
|
| 185 | + | item.tooltip = md; |
|
| 175 | 186 | return item; |
|
| 176 | 187 | } |
|
| 177 | 188 | } |
|
| … | 47 unchanged lines |
||
| 225 | 236 | try { |
|
| 226 | 237 | const started = await this.auth.clientFor(target.base).startFlowRun(target.owner, target.name, body); |
|
| 227 | 238 | vscode.window.setStatusBarMessage(`$(play) OxygenFlow run #${started.number} started`, 4000); |
|
| 239 | + | await this.refresh(); |
|
| 240 | + | } catch (e) { |
|
| 241 | + | vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`); |
|
| 242 | + | } |
|
| 243 | + | } |
|
| 244 | + | ||
| 245 | + | /** Approve or reject a job waiting on its approval rules. */ |
|
| 246 | + | async decide(node: JobNode, approve: boolean): Promise<void> { |
|
| 247 | + | if (!approve) { |
|
| 248 | + | const ok = await vscode.window.showWarningMessage(`Reject ${node.job.name} in run #${node.run.number}?`, { modal: true, detail: "The job does not run, and the run fails." }, "Reject"); |
|
| 249 | + | if (!ok) { |
|
| 250 | + | return; |
|
| 251 | + | } |
|
| 252 | + | } |
|
| 253 | + | try { |
|
| 254 | + | await this.auth.clientFor(node.repo.base).decideFlowJob(node.repo.owner, node.repo.name, node.run.number, node.job.name, approve); |
|
| 255 | + | vscode.window.setStatusBarMessage(`$(${approve ? "pass" : "close"}) ${node.job.name} ${approve ? "approved" : "rejected"}`, 4000); |
|
| 256 | + | await this.refresh(); |
|
| 257 | + | } catch (e) { |
|
| 258 | + | vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`); |
|
| 259 | + | } |
|
| 260 | + | } |
|
| 261 | + | ||
| 262 | + | /** Make a live run urgent, saying why: it goes ahead of the queue, within the owner's cap. */ |
|
| 263 | + | async expedite(node: RunNode): Promise<void> { |
|
| 264 | + | const reason = await vscode.window.showInputBox({ prompt: `Why is run #${node.run.number} urgent?`, placeHolder: "a fix for the outage", validateInput: (v) => (v.trim() ? undefined : "Say why; the run page shows it") }); |
|
| 265 | + | if (!reason) { |
|
| 266 | + | return; |
|
| 267 | + | } |
|
| 268 | + | try { |
|
| 269 | + | await this.auth.clientFor(node.repo.base).expediteFlowRun(node.repo.owner, node.repo.name, node.run.number, reason.trim()); |
|
| 270 | + | vscode.window.setStatusBarMessage(`$(rocket) Run #${node.run.number} expedited`, 4000); |
|
| 271 | + | await this.refresh(); |
|
| 272 | + | } catch (e) { |
|
| 273 | + | vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`); |
|
| 274 | + | } |
|
| 275 | + | } |
|
| 276 | + | ||
| 277 | + | async cancel(node: RunNode): Promise<void> { |
|
| 278 | + | try { |
|
| 279 | + | await this.auth.clientFor(node.repo.base).cancelFlowRun(node.repo.owner, node.repo.name, node.run.number); |
|
| 228 | 280 | await this.refresh(); |
|
| 281 | + | } catch (e) { |
|
| 282 | + | vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`); |
|
| 283 | + | } |
|
| 284 | + | } |
|
| 285 | + | ||
| 286 | + | /** The reports a job published: pick one to open it on the hub. */ |
|
| 287 | + | async reports(node: JobNode): Promise<void> { |
|
| 288 | + | try { |
|
| 289 | + | const list = await this.auth.clientFor(node.repo.base).jobReports(node.repo.owner, node.repo.name, node.run.number, node.job.name); |
|
| 290 | + | if (!list.length) { |
|
| 291 | + | void vscode.window.showInformationMessage(`OxygenFlow: ${node.job.name} published no report.`); |
|
| 292 | + | return; |
|
| 293 | + | } |
|
| 294 | + | const pick = await vscode.window.showQuickPick( |
|
| 295 | + | list.map((r) => ({ |
|
| 296 | + | label: `$(graph) ${r.title || r.name}`, |
|
| 297 | + | description: r.summary, |
|
| 298 | + | detail: r.metrics.map((m) => `${m.key} ${m.value}${m.unit ? ` ${m.unit}` : ""}`).join(" · ") || undefined, |
|
| 299 | + | r, |
|
| 300 | + | })), |
|
| 301 | + | { placeHolder: `Reports of ${node.job.name} in run #${node.run.number}` }, |
|
| 302 | + | ); |
|
| 303 | + | if (pick) { |
|
| 304 | + | void vscode.env.openExternal(vscode.Uri.parse(pick.r.url.startsWith("/") ? `${node.repo.base}${pick.r.url}` : pick.r.url)); |
|
| 305 | + | } |
|
| 229 | 306 | } catch (e) { |
|
| 230 | 307 | vscode.window.showErrorMessage(`OxygenFlow: ${(e as Error).message}`); |
|
| 231 | 308 | } |
|
| … | 80 unchanged lines |
modified src/flowview.ts +51 −1
| … | 1 unchanged lines |
||
| 2 | 2 | // colour, labels) and which runs just finished. Shared by the Flow view, the |
|
| 3 | 3 | // status bar item and the tests. |
|
| 4 | 4 | ||
| 5 | - | import type { FlowRun } from "./hubapi"; |
|
| 5 | + | import type { FlowJob, FlowRun, TestCase } from "./hubapi"; |
|
| 6 | 6 | ||
| 7 | 7 | /** A codicon and a theme colour id for a run's or job's status. */ |
|
| 8 | 8 | export interface Look { |
|
| … | 87 unchanged lines |
||
| 96 | 96 | return was !== undefined && live(was) && !live(run.status); |
|
| 97 | 97 | }); |
|
| 98 | 98 | } |
|
| 99 | + | ||
| 100 | + | /** Whether a job waits on someone's approval before it runs (oxygenflow#57). */ |
|
| 101 | + | export function waitingApproval(job: FlowJob): boolean { |
|
| 102 | + | return job.approval === "waiting" && job.status === "queued"; |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | /** MiB as the hub's pages say it: "900 MiB", "2.1 GiB". */ |
|
| 106 | + | function mib(n: number): string { |
|
| 107 | + | return n >= 1024 ? `${(n / 1024).toFixed(1)} GiB` : `${n} MiB`; |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /** |
|
| 111 | + | * Where a job's time went (#348): how long it waited for a runner, each |
|
| 112 | + | * phase its runner reported, and its peak disk and memory. Empty when the |
|
| 113 | + | * hub reported none of it. |
|
| 114 | + | */ |
|
| 115 | + | export function jobTimings(job: FlowJob): string[] { |
|
| 116 | + | const out: string[] = []; |
|
| 117 | + | if (job.queued_ms && job.started_ms) { |
|
| 118 | + | out.push(`waited ${duration(job.started_ms - job.queued_ms)}`); |
|
| 119 | + | } |
|
| 120 | + | for (const p of job.phases ?? []) { |
|
| 121 | + | out.push(`${p.name} ${p.ms < 1000 ? `${p.ms} ms` : duration(p.ms)}`); |
|
| 122 | + | } |
|
| 123 | + | const peaks = [job.peak_disk_mib ? `disk ${mib(job.peak_disk_mib)}` : "", job.peak_memory_mib ? `memory ${mib(job.peak_memory_mib)}` : ""].filter(Boolean); |
|
| 124 | + | if (peaks.length) { |
|
| 125 | + | out.push(`peak ${peaks.join(", ")}`); |
|
| 126 | + | } |
|
| 127 | + | return out; |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /** A test report's cases by suite, in the order they came, for the Testing view. */ |
|
| 131 | + | export function casesBySuite(cases: TestCase[]): Map<string, TestCase[]> { |
|
| 132 | + | const out = new Map<string, TestCase[]>(); |
|
| 133 | + | for (const c of cases) { |
|
| 134 | + | const suite = c.suite || c.classname || "tests"; |
|
| 135 | + | out.set(suite, [...(out.get(suite) ?? []), c]); |
|
| 136 | + | } |
|
| 137 | + | return out; |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | /** A case's id in the Testing view: stable across reports, so its history lines up. */ |
|
| 141 | + | export function caseId(repo: string, c: TestCase): string { |
|
| 142 | + | return `${repo}::${c.suite || c.classname || "tests"}::${c.classname}::${c.name}`; |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | /** What VS Code should show for a case's status: passed, failed, errored or skipped. */ |
|
| 146 | + | export function caseOutcome(status: string): "passed" | "failed" | "errored" | "skipped" { |
|
| 147 | + | return status === "passed" ? "passed" : status === "failed" ? "failed" : status === "error" ? "errored" : "skipped"; |
|
| 148 | + | } |
modified src/hub.ts +173 −0
| … | 222 unchanged lines |
||
| 223 | 223 | search(q: string): Promise<{ kind: string; repository: string; number: number; title: string }[]> { |
|
| 224 | 224 | return this.request("GET", `/search?q=${encodeURIComponent(q)}`); |
|
| 225 | 225 | } |
|
| 226 | + | ||
| 227 | + | /** The caller's drops, newest first (oxygenhub#280). */ |
|
| 228 | + | drops(): Promise<api.Drop[]> { |
|
| 229 | + | return this.request("GET", "/drops"); |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | /** Someone's public drops. */ |
|
| 233 | + | userDrops(user: string): Promise<api.Drop[]> { |
|
| 234 | + | return this.request("GET", `/users/${encodeURIComponent(user)}/drops`); |
|
| 235 | + | } |
|
| 236 | + | ||
| 237 | + | /** A drop with each text file's content. */ |
|
| 238 | + | drop(id: string): Promise<api.Drop> { |
|
| 239 | + | return this.request("GET", `/drops/${id}`); |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | createDrop(d: api.NewDrop): Promise<api.Drop> { |
|
| 243 | + | return this.request("POST", "/drops", d); |
|
| 244 | + | } |
|
| 245 | + | ||
| 246 | + | /** Change a drop: its title, description, visibility, or files (content null removes one). */ |
|
| 247 | + | editDrop(id: string, e: api.DropEdit): Promise<api.Drop> { |
|
| 248 | + | return this.request("PATCH", `/drops/${id}`, e); |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | deleteDrop(id: string): Promise<void> { |
|
| 252 | + | return this.request("DELETE", `/drops/${id}`); |
|
| 253 | + | } |
|
| 254 | + | ||
| 255 | + | /** A copy of a drop, history and all, as the caller's (oxygenhub#349). */ |
|
| 256 | + | forkDrop(id: string): Promise<api.Drop> { |
|
| 257 | + | return this.request("POST", `/drops/${id}/fork`); |
|
| 258 | + | } |
|
| 259 | + | ||
| 260 | + | starDrop(id: string, on: boolean): Promise<api.DropStars> { |
|
| 261 | + | return this.request(on ? "PUT" : "DELETE", `/drops/${id}/star`); |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | dropComments(id: string): Promise<api.DropComment[]> { |
|
| 265 | + | return this.request("GET", `/drops/${id}/comments`); |
|
| 266 | + | } |
|
| 267 | + | ||
| 268 | + | commentDrop(id: string, body: string): Promise<api.DropComment> { |
|
| 269 | + | return this.request("POST", `/drops/${id}/comments`, { body }); |
|
| 270 | + | } |
|
| 271 | + | ||
| 272 | + | deleteDropComment(id: string, n: number): Promise<void> { |
|
| 273 | + | return this.request("DELETE", `/drops/${id}/comments/${n}`); |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | /** Approve or reject a job waiting on its approval rules (oxygenflow#57). */ |
|
| 277 | + | decideFlowJob(owner: string, name: string, number: number, job: string, approve: boolean): Promise<unknown> { |
|
| 278 | + | return this.request("POST", `/repos/${owner}/${name}/flow/runs/${number}/jobs/${encodeURIComponent(job)}/${approve ? "approve" : "reject"}`); |
|
| 279 | + | } |
|
| 280 | + | ||
| 281 | + | /** Make a queued or running run urgent, with why (oxygenhub#244). */ |
|
| 282 | + | expediteFlowRun(owner: string, name: string, number: number, reason: string): Promise<unknown> { |
|
| 283 | + | return this.request("POST", `/repos/${owner}/${name}/flow/runs/${number}/expedite`, { reason }); |
|
| 284 | + | } |
|
| 285 | + | ||
| 286 | + | cancelFlowRun(owner: string, name: string, number: number): Promise<unknown> { |
|
| 287 | + | return this.request("POST", `/repos/${owner}/${name}/flow/runs/${number}/cancel`); |
|
| 288 | + | } |
|
| 289 | + | ||
| 290 | + | /** The reports a job published (oxygenhub#348). */ |
|
| 291 | + | jobReports(owner: string, name: string, number: number, job: string): Promise<api.Report[]> { |
|
| 292 | + | return this.request("GET", `/repos/${owner}/${name}/flow/runs/${number}/jobs/${encodeURIComponent(job)}/reports`); |
|
| 293 | + | } |
|
| 294 | + | ||
| 295 | + | /** Test reports on a bookmark, newest first. */ |
|
| 296 | + | testReports(owner: string, name: string, bookmark?: string, limit = 1): Promise<api.TestReport[]> { |
|
| 297 | + | const q = new URLSearchParams({ limit: String(limit), ...(bookmark ? { bookmark } : {}) }); |
|
| 298 | + | return this.request("GET", `/repos/${owner}/${name}/tests?${q}`); |
|
| 299 | + | } |
|
| 300 | + | ||
| 301 | + | testReport(owner: string, name: string, id: number): Promise<api.TestReportDetail> { |
|
| 302 | + | return this.request("GET", `/repos/${owner}/${name}/tests/${id}`); |
|
| 303 | + | } |
|
| 304 | + | ||
| 305 | + | /** Make a draft ready for review, or turn an open proposal back into a draft (#313). */ |
|
| 306 | + | setProposalDraft(owner: string, name: string, number: number, draft: boolean): Promise<api.Proposal> { |
|
| 307 | + | return this.request("PATCH", `/repos/${owner}/${name}/proposals/${number}`, { draft }); |
|
| 308 | + | } |
|
| 309 | + | ||
| 310 | + | /** The dashboard's proposals; with `landable`, also what the caller can land now (#311). */ |
|
| 311 | + | userProposals(landable = false): Promise<api.UserProposals> { |
|
| 312 | + | return this.request("GET", `/user/proposals${landable ? "?landable=true" : ""}`); |
|
| 313 | + | } |
|
| 314 | + | ||
| 315 | + | /** What a release's trust page shows: signatures, builds and the audit (#79). */ |
|
| 316 | + | releaseTrust(owner: string, name: string, tag: string): Promise<ReleaseTrust> { |
|
| 317 | + | return this.request("GET", `/repos/${owner}/${name}/releases/trust/${encodeURIComponent(tag)}`); |
|
| 318 | + | } |
|
| 319 | + | ||
| 320 | + | orgs(): Promise<api.Organisation[]> { |
|
| 321 | + | return this.request("GET", "/user/orgs"); |
|
| 322 | + | } |
|
| 323 | + | ||
| 324 | + | createRepository(r: api.NewRepository): Promise<api.Repository> { |
|
| 325 | + | return this.request("POST", "/repos", r); |
|
| 326 | + | } |
|
| 327 | + | ||
| 328 | + | /** The assistants a new repository under `owner` may ask for its starting files (#347). */ |
|
| 329 | + | starterAssistants(owner: string): Promise<api.StarterAssistants> { |
|
| 330 | + | return this.request("GET", `/user/starter?owner=${encodeURIComponent(owner)}`); |
|
| 331 | + | } |
|
| 332 | + | ||
| 333 | + | startStarter(owner: string, name: string, r: api.StarterRequest): Promise<api.StarterStatus> { |
|
| 334 | + | return this.request("POST", `/repos/${owner}/${name}/starter`, r); |
|
| 335 | + | } |
|
| 336 | + | ||
| 337 | + | starterStatus(owner: string, name: string): Promise<api.StarterStatus> { |
|
| 338 | + | return this.request("GET", `/repos/${owner}/${name}/starter`); |
|
| 339 | + | } |
|
| 340 | + | ||
| 341 | + | /** A file's bytes from the hub (a release asset), with the session's token. */ |
|
| 342 | + | async download(url: string): Promise<Uint8Array> { |
|
| 343 | + | const headers: Record<string, string> = {}; |
|
| 344 | + | if (this.token && url.startsWith(this.base + "/")) { |
|
| 345 | + | headers.authorization = `Bearer ${this.token}`; |
|
| 346 | + | } |
|
| 347 | + | const res = await this.fetchImpl(url, { headers }); |
|
| 348 | + | if (!res.ok) { |
|
| 349 | + | throw new HubError(res.status, res.statusText, await res.text()); |
|
| 350 | + | } |
|
| 351 | + | return new Uint8Array(await res.arrayBuffer()); |
|
| 352 | + | } |
|
| 353 | + | } |
|
| 354 | + | ||
| 355 | + | /** A release's trust report (the web's, not in the generated types). */ |
|
| 356 | + | export interface ReleaseTrust { |
|
| 357 | + | repository: string; |
|
| 358 | + | tag: string; |
|
| 359 | + | title: string; |
|
| 360 | + | release_url: string; |
|
| 361 | + | heads: string[]; |
|
| 362 | + | tag_signer?: { key: string; name: string; email: string; account?: string | null } | null; |
|
| 363 | + | /** `valid`, `unsigned`, `invalid: …` or `revoked`. */ |
|
| 364 | + | tag_signature: string; |
|
| 365 | + | since?: string | null; |
|
| 366 | + | changes: number; |
|
| 367 | + | authors: { who: { name: string; email: string; account?: string | null }; changes: number; signed: number; sealed: number; unsigned: number; revoked: number; invalid: number }[]; |
|
| 368 | + | assets: { name: string; size: number; blake3: string; url: string; build?: { run: number; job: string; run_url: string; same_state_as_tag: boolean } | null }[]; |
|
| 369 | + | audit: { verdict: "pass" | "warn" | "fail"; title: string; detail: string }[]; |
|
| 370 | + | generated_ms: number; |
|
| 371 | + | } |
|
| 372 | + | ||
| 373 | + | /** A release's trust report as Markdown, for a preview beside the code. */ |
|
| 374 | + | export function trustMarkdown(t: ReleaseTrust): string { |
|
| 375 | + | const glyph = { pass: "✔", warn: "!", fail: "✘" } as const; |
|
| 376 | + | const out = [`# Trust: ${t.title || t.tag}`, "", `${t.repository} · tag \`${t.tag}\` · ${t.heads.map((h) => h.slice(0, 12)).join("+")}`, ""]; |
|
| 377 | + | const signer = t.tag_signer ? `${t.tag_signer.name} <${t.tag_signer.email}>` : ""; |
|
| 378 | + | out.push(`- Tag signature: **${t.tag_signature}**${signer ? ` by ${signer}` : ""}`); |
|
| 379 | + | out.push(`- ${t.changes} change${t.changes === 1 ? "" : "s"}${t.since ? ` since ${t.since}` : ""}`, ""); |
|
| 380 | + | out.push("## Audit", ""); |
|
| 381 | + | for (const c of t.audit) { |
|
| 382 | + | out.push(`- ${glyph[c.verdict]} **${c.title}**${c.detail ? ` — ${c.detail}` : ""}`); |
|
| 383 | + | } |
|
| 384 | + | if (t.authors.length) { |
|
| 385 | + | out.push("", "## Authors", "", "| Author | Changes | Signed | Sealed | Unsigned | Revoked | Invalid |", "|---|---:|---:|---:|---:|---:|---:|"); |
|
| 386 | + | for (const a of t.authors) { |
|
| 387 | + | out.push(`| ${a.who.account ?? a.who.name} | ${a.changes} | ${a.signed} | ${a.sealed} | ${a.unsigned} | ${a.revoked} | ${a.invalid} |`); |
|
| 388 | + | } |
|
| 389 | + | } |
|
| 390 | + | if (t.assets.length) { |
|
| 391 | + | out.push("", "## Assets", ""); |
|
| 392 | + | for (const a of t.assets) { |
|
| 393 | + | const build = a.build ? `built by [run #${a.build.run}](${a.build.run_url}) ${a.build.job}${a.build.same_state_as_tag ? "" : " (not the tagged state)"}` : "no attested build"; |
|
| 394 | + | out.push(`- \`${a.name}\` · BLAKE3 \`${a.blake3.slice(0, 16)}…\` · ${build}`); |
|
| 395 | + | } |
|
| 396 | + | } |
|
| 397 | + | out.push("", `[The release](${t.release_url})`, ""); |
|
| 398 | + | return out.join("\n"); |
|
| 226 | 399 | } |
|
| 227 | 400 | ||
| 228 | 401 | /** Web URLs on the hub for things the remote provider is asked about. */ |
|
| … | 143 unchanged lines |
modified src/hubapi.d.ts +200 −0
| … | 133 unchanged lines |
||
| 134 | 134 | export type Proposal = { |
|
| 135 | 135 | approvals: Approval[]; |
|
| 136 | 136 | author: string; |
|
| 137 | + | auto_land?: string | null; |
|
| 137 | 138 | blockers: string[]; |
|
| 138 | 139 | body: string; |
|
| 139 | 140 | changes: ChangeSummary[]; |
|
| … | 86 unchanged lines |
||
| 226 | 227 | at_ms: number; |
|
| 227 | 228 | id: number; |
|
| 228 | 229 | kind: string; |
|
| 230 | + | label?: string | null; |
|
| 229 | 231 | read: boolean; |
|
| 232 | + | reason?: string; |
|
| 233 | + | repository?: string | null; |
|
| 234 | + | thread?: string; |
|
| 230 | 235 | title: string; |
|
| 231 | 236 | url: string; |
|
| 232 | 237 | }; |
|
| … | 29 unchanged lines |
||
| 262 | 267 | line: number; |
|
| 263 | 268 | text: string; |
|
| 264 | 269 | when_ms: number; |
|
| 270 | + | }; |
|
| 271 | + | ||
| 272 | + | export type DiffLine = { |
|
| 273 | + | kind: string; |
|
| 274 | + | new?: number | null; |
|
| 275 | + | old?: number | null; |
|
| 276 | + | text?: string; |
|
| 265 | 277 | }; |
|
| 266 | 278 | ||
| 267 | 279 | export type FileChange = { |
|
| 268 | 280 | added: number; |
|
| 269 | 281 | from?: string | null; |
|
| 270 | 282 | kind: string; |
|
| 283 | + | lines?: DiffLine[]; |
|
| 271 | 284 | path: string; |
|
| 272 | 285 | removed: number; |
|
| 286 | + | truncated?: boolean; |
|
| 273 | 287 | }; |
|
| 274 | 288 | ||
| 275 | 289 | export type ChangeDetail = { |
|
| … | 140 unchanged lines |
||
| 416 | 430 | ||
| 417 | 431 | export type ProposalEdit = { |
|
| 418 | 432 | body?: string | null; |
|
| 433 | + | draft?: boolean | null; |
|
| 419 | 434 | labels?: string[] | null; |
|
| 420 | 435 | state?: string | null; |
|
| 421 | 436 | title?: string | null; |
|
| … | 21 unchanged lines |
||
| 443 | 458 | }; |
|
| 444 | 459 | ||
| 445 | 460 | export type ProjectCard = { |
|
| 461 | + | archived_ms?: number | null; |
|
| 462 | + | assignees?: string[]; |
|
| 446 | 463 | id: number; |
|
| 447 | 464 | kind: string; |
|
| 465 | + | labels?: string[]; |
|
| 448 | 466 | reference: string; |
|
| 449 | 467 | state: string; |
|
| 450 | 468 | title: string; |
|
| … | 76 unchanged lines |
||
| 527 | 545 | export type AssetBuild = { |
|
| 528 | 546 | artifact: string; |
|
| 529 | 547 | job: string; |
|
| 548 | + | provenance_url?: string | null; |
|
| 530 | 549 | receipt?: string | null; |
|
| 531 | 550 | receipt_url?: string | null; |
|
| 532 | 551 | run: number; |
|
| … | 27 unchanged lines |
||
| 560 | 579 | }; |
|
| 561 | 580 | ||
| 562 | 581 | export type FlowJob = { |
|
| 582 | + | approval?: string; |
|
| 563 | 583 | depends_on: string[]; |
|
| 564 | 584 | error: string; |
|
| 565 | 585 | exit_code?: number | null; |
|
| 566 | 586 | finished_ms?: number | null; |
|
| 567 | 587 | log_lines: number; |
|
| 568 | 588 | name: string; |
|
| 589 | + | peak_disk_mib?: number | null; |
|
| 590 | + | peak_memory_mib?: number | null; |
|
| 591 | + | phases?: FlowPhase[]; |
|
| 569 | 592 | placed?: string; |
|
| 593 | + | queued_ms?: number | null; |
|
| 594 | + | ready_ms?: number | null; |
|
| 570 | 595 | runner: string; |
|
| 571 | 596 | started_ms?: number | null; |
|
| 572 | 597 | status: string; |
|
| 573 | 598 | waiting?: string; |
|
| 574 | 599 | }; |
|
| 575 | 600 | ||
| 601 | + | export type FlowPhase = { |
|
| 602 | + | ms: number; |
|
| 603 | + | name: string; |
|
| 604 | + | }; |
|
| 605 | + | ||
| 576 | 606 | export type FlowRun = { |
|
| 577 | 607 | actor: string; |
|
| 578 | 608 | bookmark: string; |
|
| … | 16 unchanged lines |
||
| 595 | 625 | stream: string; |
|
| 596 | 626 | t: number; |
|
| 597 | 627 | text: string; |
|
| 628 | + | }; |
|
| 629 | + | ||
| 630 | + | export type ReportFile = { |
|
| 631 | + | path: string; |
|
| 632 | + | size: number; |
|
| 633 | + | }; |
|
| 634 | + | ||
| 635 | + | export type ReportMetric = { |
|
| 636 | + | better: string; |
|
| 637 | + | fail_above?: number | null; |
|
| 638 | + | fail_below?: number | null; |
|
| 639 | + | key: string; |
|
| 640 | + | unit: string; |
|
| 641 | + | value: number; |
|
| 642 | + | }; |
|
| 643 | + | ||
| 644 | + | export type Report = { |
|
| 645 | + | bookmark: string; |
|
| 646 | + | bytes: number; |
|
| 647 | + | context: unknown; |
|
| 648 | + | created_ms: number; |
|
| 649 | + | files: number; |
|
| 650 | + | files_expired_ms?: number | null; |
|
| 651 | + | id: number; |
|
| 652 | + | job: string; |
|
| 653 | + | kind: string; |
|
| 654 | + | manifest?: ReportFile[]; |
|
| 655 | + | metrics: ReportMetric[]; |
|
| 656 | + | name: string; |
|
| 657 | + | run?: number | null; |
|
| 658 | + | state: string; |
|
| 659 | + | summary: string; |
|
| 660 | + | title: string; |
|
| 661 | + | url: string; |
|
| 598 | 662 | }; |
|
| 599 | 663 | ||
| 600 | 664 | export type NewRelease = { |
|
| 601 | 665 | notes?: string | null; |
|
| 602 | 666 | prerelease?: boolean; |
|
| 603 | 667 | tag: string; |
|
| 668 | + | title: string; |
|
| 669 | + | }; |
|
| 670 | + | ||
| 671 | + | export type DropFile = { |
|
| 672 | + | content?: string | null; |
|
| 673 | + | name: string; |
|
| 674 | + | raw_url: string; |
|
| 675 | + | size: number; |
|
| 676 | + | }; |
|
| 677 | + | ||
| 678 | + | export type Drop = { |
|
| 679 | + | clone_url: string; |
|
| 680 | + | created_ms: number; |
|
| 681 | + | description: string; |
|
| 682 | + | embed_url?: string; |
|
| 683 | + | files: DropFile[]; |
|
| 684 | + | forked_from?: string | null; |
|
| 685 | + | id: string; |
|
| 686 | + | owner: string; |
|
| 687 | + | starred?: boolean; |
|
| 688 | + | stars?: number; |
|
| 689 | + | title: string; |
|
| 690 | + | updated_ms: number; |
|
| 691 | + | url: string; |
|
| 692 | + | visibility: string; |
|
| 693 | + | }; |
|
| 694 | + | ||
| 695 | + | export type DropComment = { |
|
| 696 | + | author: string; |
|
| 697 | + | body: string; |
|
| 698 | + | created_ms: number; |
|
| 699 | + | id: number; |
|
| 700 | + | }; |
|
| 701 | + | ||
| 702 | + | export type NewDropComment = { |
|
| 703 | + | body: string; |
|
| 704 | + | }; |
|
| 705 | + | ||
| 706 | + | export type DropStars = { |
|
| 707 | + | starred: boolean; |
|
| 708 | + | stars: number; |
|
| 709 | + | }; |
|
| 710 | + | ||
| 711 | + | export type NewDropFile = { |
|
| 712 | + | content: string; |
|
| 713 | + | name: string; |
|
| 714 | + | }; |
|
| 715 | + | ||
| 716 | + | export type NewDrop = { |
|
| 717 | + | description?: string; |
|
| 718 | + | files: NewDropFile[]; |
|
| 719 | + | secret?: boolean; |
|
| 720 | + | title?: string; |
|
| 721 | + | }; |
|
| 722 | + | ||
| 723 | + | export type DropFileEdit = { |
|
| 724 | + | content?: string | null; |
|
| 725 | + | name: string; |
|
| 726 | + | }; |
|
| 727 | + | ||
| 728 | + | export type DropEdit = { |
|
| 729 | + | description?: string | null; |
|
| 730 | + | files?: DropFileEdit[]; |
|
| 731 | + | message?: string | null; |
|
| 732 | + | secret?: boolean | null; |
|
| 733 | + | title?: string | null; |
|
| 734 | + | }; |
|
| 735 | + | ||
| 736 | + | export type ProposalBrief = { |
|
| 737 | + | author: string; |
|
| 738 | + | conflicted: boolean; |
|
| 739 | + | number: number; |
|
| 740 | + | repository: string; |
|
| 741 | + | state: ProposalState; |
|
| 604 | 742 | title: string; |
|
| 743 | + | updated_ms: number; |
|
| 744 | + | url: string; |
|
| 745 | + | }; |
|
| 746 | + | ||
| 747 | + | export type UserProposals = { |
|
| 748 | + | authored: ProposalBrief[]; |
|
| 749 | + | landable?: ProposalBrief[]; |
|
| 750 | + | review: ProposalBrief[]; |
|
| 751 | + | }; |
|
| 752 | + | ||
| 753 | + | export type StarterAssistant = { |
|
| 754 | + | from: string; |
|
| 755 | + | model: string; |
|
| 756 | + | name: string; |
|
| 757 | + | }; |
|
| 758 | + | ||
| 759 | + | export type StarterAssistants = { |
|
| 760 | + | assistants: StarterAssistant[]; |
|
| 761 | + | }; |
|
| 762 | + | ||
| 763 | + | export type StarterStatus = { |
|
| 764 | + | assistant?: string; |
|
| 765 | + | change?: string; |
|
| 766 | + | error?: string; |
|
| 767 | + | files?: string[]; |
|
| 768 | + | status: string; |
|
| 769 | + | }; |
|
| 770 | + | ||
| 771 | + | export type StarterRequest = { |
|
| 772 | + | assistant?: string | null; |
|
| 773 | + | description: string; |
|
| 774 | + | }; |
|
| 775 | + | ||
| 776 | + | export type TestReport = { |
|
| 777 | + | bookmark: string; |
|
| 778 | + | created_ms: number; |
|
| 779 | + | errors: number; |
|
| 780 | + | failed: number; |
|
| 781 | + | id: number; |
|
| 782 | + | job: string; |
|
| 783 | + | passed: number; |
|
| 784 | + | run: number; |
|
| 785 | + | skipped: number; |
|
| 786 | + | state: string; |
|
| 787 | + | tests: number; |
|
| 788 | + | time_ms: number; |
|
| 789 | + | url: string; |
|
| 790 | + | }; |
|
| 791 | + | ||
| 792 | + | export type TestCase = { |
|
| 793 | + | classname: string; |
|
| 794 | + | message: string; |
|
| 795 | + | name: string; |
|
| 796 | + | output: string; |
|
| 797 | + | status: string; |
|
| 798 | + | suite: string; |
|
| 799 | + | time_ms: number; |
|
| 800 | + | }; |
|
| 801 | + | ||
| 802 | + | export type TestReportDetail = { |
|
| 803 | + | cases: TestCase[]; |
|
| 804 | + | report: TestReport; |
|
| 605 | 805 | }; |
|
| 606 | 806 |
modified src/proposals.ts +2 −1
| … | 117 unchanged lines |
||
| 118 | 118 | item.description = `${p.author} → ${p.target}${chips ? " · " + chips : ""}`; |
|
| 119 | 119 | item.tooltip = new vscode.MarkdownString(`**${p.title}** by ${p.author}\n\n${p.changes.length} change(s) into ${p.target}\n\n${p.blockers.length ? "Blocked: " + p.blockers.join("; ") : "Ready to land"}`); |
|
| 120 | 120 | item.iconPath = new vscode.ThemeIcon(p.conflicted ? "warning" : p.landable ? "pass" : p.state === "draft" ? "circle-outline" : "git-pull-request"); |
|
| 121 | - | item.contextValue = "proposal"; |
|
| 121 | + | // `proposal` or `proposalDraft`: a draft is marked ready, an open one turned back into a draft. |
|
| 122 | + | item.contextValue = p.state === "draft" ? "proposalDraft" : "proposal"; |
|
| 122 | 123 | item.command = { title: "Open", command: "oxygenhub.openProposal", arguments: [node] }; |
|
| 123 | 124 | return item; |
|
| 124 | 125 | } |
|
| … | 78 unchanged lines |
modified src/releases.ts +24 −1
| … | 3 unchanged lines |
||
| 4 | 4 | import * as vscode from "vscode"; |
|
| 5 | 5 | import type * as api from "./hubapi"; |
|
| 6 | 6 | import { HubAuth } from "./auth"; |
|
| 7 | - | import { looksPrerelease, releaseNotes } from "./hub"; |
|
| 7 | + | import { looksPrerelease, releaseNotes, trustMarkdown } from "./hub"; |
|
| 8 | 8 | import type { HubRepo } from "./proposals"; |
|
| 9 | 9 | ||
| 10 | 10 | type Node = |
|
| … | 2 unchanged lines |
||
| 13 | 13 | | { kind: "asset"; repo: HubRepo; r: api.Release; name: string; url: string; size?: number; downloads?: number }; |
|
| 14 | 14 | ||
| 15 | 15 | export type ReleaseNode = Extract<Node, { kind: "release" }>; |
|
| 16 | + | export type AssetNode = Extract<Node, { kind: "asset" }>; |
|
| 16 | 17 | ||
| 17 | 18 | export class ReleasesProvider implements vscode.TreeDataProvider<Node> { |
|
| 18 | 19 | private readonly emitter = new vscode.EventEmitter<void>(); |
|
| … | 30 unchanged lines |
||
| 49 | 50 | const item = new vscode.TreeItem(node.name, vscode.TreeItemCollapsibleState.None); |
|
| 50 | 51 | item.description = node.size !== undefined ? `${formatSize(node.size)} · ${node.downloads ?? 0} downloads` : "source archive"; |
|
| 51 | 52 | item.iconPath = new vscode.ThemeIcon(node.size !== undefined ? "package" : "file-zip"); |
|
| 53 | + | item.contextValue = "releaseAsset"; |
|
| 52 | 54 | item.command = { title: "Download", command: "vscode.open", arguments: [vscode.Uri.parse(node.url)] }; |
|
| 53 | 55 | return item; |
|
| 54 | 56 | } |
|
| … | 63 unchanged lines |
||
| 118 | 120 | } |
|
| 119 | 121 | return client.createRelease(repo.owner, repo.name, { tag: pick.label, title: title || pick.label, notes, prerelease: pre.pre }); |
|
| 120 | 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 | + | } |
modified test/flowview.test.mjs +22 −1
| 1 | 1 | import { test } from "node:test"; |
|
| 2 | 2 | import assert from "node:assert/strict"; |
|
| 3 | - | import { ago, duration, live, look, newlyFinished, runDescription, runLabel } from "../out/flowview.mjs"; |
|
| 3 | + | import { ago, caseId, caseOutcome, casesBySuite, duration, jobTimings, live, look, newlyFinished, runDescription, runLabel, waitingApproval } from "../out/flowview.mjs"; |
|
| 4 | 4 | ||
| 5 | 5 | const run = (over = {}) => ({ number: 12, bookmark: "main", heads: ["a".repeat(64)], status: "passed", trigger: "push", actor: "andy", created_ms: 1_000_000, finished_ms: 1_125_000, url: "", state: "", pipeline_hash: "", error: "", ...over }); |
|
| 6 | 6 | ||
| … | 24 unchanged lines |
||
| 31 | 31 | ]; |
|
| 32 | 32 | assert.deepEqual(newlyFinished(before, now).map((x) => x.key), ["a#1"], "only the one that finished; new runs are not news"); |
|
| 33 | 33 | }); |
|
| 34 | + | ||
| 35 | + | test("a job's timings: its wait, its phases and its peaks", () => { |
|
| 36 | + | const job = { name: "test", status: "passed", queued_ms: 1_000, started_ms: 13_000, phases: [{ name: "image", ms: 850 }, { name: "run", ms: 125_000 }], peak_disk_mib: 2150, peak_memory_mib: 900 }; |
|
| 37 | + | assert.deepEqual(jobTimings(job), ["waited 12.0 s", "image 850 ms", "run 2 min 5 s", "peak disk 2.1 GiB, memory 900 MiB"]); |
|
| 38 | + | assert.deepEqual(jobTimings({ name: "x", status: "queued" }), [], "nothing reported, nothing said"); |
|
| 39 | + | }); |
|
| 40 | + | ||
| 41 | + | test("a job waits for approval only while queued", () => { |
|
| 42 | + | assert.ok(waitingApproval({ status: "queued", approval: "waiting" })); |
|
| 43 | + | assert.ok(!waitingApproval({ status: "running", approval: "waiting" })); |
|
| 44 | + | assert.ok(!waitingApproval({ status: "queued", approval: "approved" })); |
|
| 45 | + | }); |
|
| 46 | + | ||
| 47 | + | test("test cases by suite, with stable ids and VS Code's outcomes", () => { |
|
| 48 | + | const c = (suite, name, status) => ({ suite, classname: "cls", name, status, message: "", output: "", time_ms: 5 }); |
|
| 49 | + | const by = casesBySuite([c("a", "one", "passed"), c("b", "two", "failed"), c("a", "three", "skipped"), c("", "four", "error")]); |
|
| 50 | + | assert.deepEqual([...by.keys()], ["a", "b", "cls"]); |
|
| 51 | + | assert.deepEqual(by.get("a").map((x) => x.name), ["one", "three"]); |
|
| 52 | + | assert.equal(caseId("hub/o/n", c("a", "one", "passed")), "hub/o/n::a::cls::one"); |
|
| 53 | + | assert.deepEqual(["passed", "failed", "error", "skipped"].map(caseOutcome), ["passed", "failed", "errored", "skipped"]); |
|
| 54 | + | }); |
modified test/hub.test.mjs +107 −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, releaseNotes, looksPrerelease } from "../out/hub.mjs"; |
|
| 9 | + | import { HubClient, HubError, encodeDiffQuery, decodeDiffQuery, hubTarget, releaseNotes, looksPrerelease, trustMarkdown } 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"); |
|
| … | 135 unchanged lines |
||
| 148 | 148 | assert.equal(hubUrl("https://h", "a", "r", { kind: "file", reference: "main", path: "x.rs", line: 3, endLine: 3 }), "https://h/a/r/file/main/x.rs#L3"); |
|
| 149 | 149 | assert.equal(hubUrl("https://h", "a", "r", { kind: "file", reference: "feature/x", path: "x.rs" }), "https://h/a/r/file/feature%2Fx/x.rs"); |
|
| 150 | 150 | }); |
|
| 151 | + | ||
| 152 | + | /** A fetch that records each request and answers from `reply`. */ |
|
| 153 | + | function recorder(reply = () => ({})) { |
|
| 154 | + | const calls = []; |
|
| 155 | + | const f = async (url, init = {}) => { |
|
| 156 | + | const body = init.body ? JSON.parse(init.body) : undefined; |
|
| 157 | + | calls.push({ method: init.method ?? "GET", path: url.replace("https://hub/api/v1", ""), body, auth: new Headers(init.headers).get("authorization") }); |
|
| 158 | + | const out = reply(init.method ?? "GET", url); |
|
| 159 | + | return out === null ? new Response(null, { status: 204 }) : new Response(JSON.stringify(out), { status: 200 }); |
|
| 160 | + | }; |
|
| 161 | + | return { calls, f }; |
|
| 162 | + | } |
|
| 163 | + | ||
| 164 | + | test("drops, Flow decisions, drafts and starters go where the hub serves them", async () => { |
|
| 165 | + | const { calls, f } = recorder((m) => (m === "DELETE" ? null : {})); |
|
| 166 | + | const c = new HubClient("https://hub", "tok", f); |
|
| 167 | + | const id = "0123456789abcdef01234567"; |
|
| 168 | + | await c.drops(); |
|
| 169 | + | await c.userDrops("sky blue"); |
|
| 170 | + | await c.drop(id); |
|
| 171 | + | await c.createDrop({ title: "t", secret: true, files: [{ name: "a.md", content: "x" }] }); |
|
| 172 | + | await c.editDrop(id, { files: [{ name: "a.md", content: null }], message: "Remove a.md" }); |
|
| 173 | + | await c.deleteDrop(id); |
|
| 174 | + | await c.forkDrop(id); |
|
| 175 | + | await c.starDrop(id, true); |
|
| 176 | + | await c.starDrop(id, false); |
|
| 177 | + | await c.dropComments(id); |
|
| 178 | + | await c.commentDrop(id, "hi"); |
|
| 179 | + | await c.deleteDropComment(id, 3); |
|
| 180 | + | await c.decideFlowJob("o", "n", 7, "deploy prod", true); |
|
| 181 | + | await c.decideFlowJob("o", "n", 7, "deploy", false); |
|
| 182 | + | await c.expediteFlowRun("o", "n", 7, "outage"); |
|
| 183 | + | await c.jobReports("o", "n", 7, "bench"); |
|
| 184 | + | await c.testReports("o", "n", "main", 1); |
|
| 185 | + | await c.testReport("o", "n", 4); |
|
| 186 | + | await c.setProposalDraft("o", "n", 2, false); |
|
| 187 | + | await c.userProposals(true); |
|
| 188 | + | await c.releaseTrust("o", "n", "v1.0"); |
|
| 189 | + | await c.createRepository({ owner: "o", name: "n2" }); |
|
| 190 | + | await c.starterAssistants("my org"); |
|
| 191 | + | await c.startStarter("o", "n2", { description: "a CLI" }); |
|
| 192 | + | assert.deepEqual( |
|
| 193 | + | calls.map((x) => `${x.method} ${x.path}`), |
|
| 194 | + | [ |
|
| 195 | + | "GET /drops", |
|
| 196 | + | "GET /users/sky%20blue/drops", |
|
| 197 | + | `GET /drops/${id}`, |
|
| 198 | + | "POST /drops", |
|
| 199 | + | `PATCH /drops/${id}`, |
|
| 200 | + | `DELETE /drops/${id}`, |
|
| 201 | + | `POST /drops/${id}/fork`, |
|
| 202 | + | `PUT /drops/${id}/star`, |
|
| 203 | + | `DELETE /drops/${id}/star`, |
|
| 204 | + | `GET /drops/${id}/comments`, |
|
| 205 | + | `POST /drops/${id}/comments`, |
|
| 206 | + | `DELETE /drops/${id}/comments/3`, |
|
| 207 | + | "POST /repos/o/n/flow/runs/7/jobs/deploy%20prod/approve", |
|
| 208 | + | "POST /repos/o/n/flow/runs/7/jobs/deploy/reject", |
|
| 209 | + | "POST /repos/o/n/flow/runs/7/expedite", |
|
| 210 | + | "GET /repos/o/n/flow/runs/7/jobs/bench/reports", |
|
| 211 | + | "GET /repos/o/n/tests?limit=1&bookmark=main", |
|
| 212 | + | "GET /repos/o/n/tests/4", |
|
| 213 | + | "PATCH /repos/o/n/proposals/2", |
|
| 214 | + | "GET /user/proposals?landable=true", |
|
| 215 | + | "GET /repos/o/n/releases/trust/v1.0", |
|
| 216 | + | "POST /repos", |
|
| 217 | + | "GET /user/starter?owner=my%20org", |
|
| 218 | + | "POST /repos/o/n2/starter", |
|
| 219 | + | ], |
|
| 220 | + | ); |
|
| 221 | + | assert.deepEqual(calls[4].body, { files: [{ name: "a.md", content: null }], message: "Remove a.md" }, "a null content removes a file"); |
|
| 222 | + | assert.deepEqual(calls[10].body, { body: "hi" }); |
|
| 223 | + | assert.deepEqual(calls[14].body, { reason: "outage" }); |
|
| 224 | + | assert.deepEqual(calls[18].body, { draft: false }); |
|
| 225 | + | assert.ok(calls.every((x) => x.auth === "Bearer tok")); |
|
| 226 | + | }); |
|
| 227 | + | ||
| 228 | + | test("downloads carry the token only to the hub itself", async () => { |
|
| 229 | + | const seen = []; |
|
| 230 | + | const f = async (url, init = {}) => (seen.push([url, new Headers(init.headers).get("authorization")]), new Response(new Uint8Array([1, 2, 3]))); |
|
| 231 | + | const c = new HubClient("https://hub", "tok", f); |
|
| 232 | + | assert.deepEqual([...(await c.download("https://hub/o/n/releases/download/v1/a.tar.gz"))], [1, 2, 3]); |
|
| 233 | + | await c.download("https://elsewhere.example/a.tar.gz"); |
|
| 234 | + | assert.deepEqual(seen, [ |
|
| 235 | + | ["https://hub/o/n/releases/download/v1/a.tar.gz", "Bearer tok"], |
|
| 236 | + | ["https://elsewhere.example/a.tar.gz", null], |
|
| 237 | + | ]); |
|
| 238 | + | }); |
|
| 239 | + | ||
| 240 | + | test("a release's trust report reads as Markdown", () => { |
|
| 241 | + | const md = trustMarkdown({ |
|
| 242 | + | repository: "andy/oxygen", tag: "v1.0", title: "One", release_url: "https://hub/andy/oxygen/releases/v1.0", heads: ["a".repeat(64)], |
|
| 243 | + | tag_signer: { key: "k", name: "Andy", email: "a@x" }, tag_signature: "valid", since: "v0.9", changes: 3, |
|
| 244 | + | authors: [{ who: { name: "Andy", email: "a@x", account: "andy" }, changes: 3, signed: 2, sealed: 1, unsigned: 0, revoked: 0, invalid: 0 }], |
|
| 245 | + | assets: [{ name: "oxy.tar.gz", size: 9, blake3: "b".repeat(64), url: "", build: { run: 5, job: "dist", run_url: "https://hub/r/5", same_state_as_tag: true } }], |
|
| 246 | + | audit: [{ verdict: "pass", title: "Tag signed", detail: "" }, { verdict: "warn", title: "One author", detail: "a bus factor of one" }], |
|
| 247 | + | generated_ms: 0, |
|
| 248 | + | }); |
|
| 249 | + | assert.match(md, /^# Trust: One$/m); |
|
| 250 | + | assert.match(md, /Tag signature: \*\*valid\*\* by Andy <a@x>/); |
|
| 251 | + | assert.match(md, /3 changes since v0.9/); |
|
| 252 | + | assert.match(md, /^- ✔ \*\*Tag signed\*\*$/m); |
|
| 253 | + | assert.match(md, /^- ! \*\*One author\*\* — a bus factor of one$/m); |
|
| 254 | + | assert.match(md, /\| andy \| 3 \| 2 \| 1 \| 0 \| 0 \| 0 \|/); |
|
| 255 | + | assert.match(md, /`oxy.tar.gz` · BLAKE3 `bbbbbbbbbbbbbbbb…` · built by \[run #5\]\(https:\/\/hub\/r\/5\) dist$/m); |
|
| 256 | + | }); |