New Issue from Selection (0.5.0) 95f69b4a3ac2
The editor's context menu opens an issue quoting the selected lines (or the current one) under a permalink to them on the hub, pinned to the bookmark's head when it has one and the file is saved. A TODO, FIXME, XXX or HACK line suggests the title, and the new number can be written back as TODO(#12). File links carry line ranges. Spec §8.1.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 10:05 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
95f69b4a3ac20947ad0f0231a2729238a2fca4ff908685f44ef20b03b6b35b33- Depends on
- 486c15b00314 bf73998c5198 f0b06deac4b9 f902d5b8bfeb
- Depended on by
- 991516a107fa e04950748142 426ddc5039b5 092b8bf9f2ac 1cc0ff44e5ca
- In bookmarks
- main
- In tags
- v0.10.0 v0.10.1 v0.10.2 v0.11.0 v0.11.1 v0.11.2 v0.13.0
- Edits
- 15 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified CHANGELOG.md +5 −0
| 1 | 1 | # Changelog |
|
| 2 | 2 | ||
| 3 | + | ## 0.5.0 |
|
| 4 | + | ||
| 5 | + | - **New Issue from Selection** in the editor's context menu: the selected lines (or the current one) quoted in a new issue with a permalink to them on the hub. A TODO or FIXME line suggests the title, and the issue's number can be written back into it as `TODO(#12)`. |
|
| 6 | + | - File links on the hub can carry a line range (`#L3-L7`). |
|
| 7 | + | ||
| 3 | 8 | ## 0.4.0 |
|
| 4 | 9 | ||
| 5 | 10 | - Type `#` in the Oxygen commit box or a review comment to pick an issue or proposal by number or title, and `@` to mention someone who has opened or been assigned one. |
|
| … | 34 unchanged lines |
modified README.md +1 −0
| … | 6 unchanged lines |
||
| 7 | 7 | ||
| 8 | 8 | - **Sign in** with a hub URL and a personal access token (Settings › Tokens on the hub). |
|
| 9 | 9 | - **Proposals** view: needing me, mine, open; detail with blockers, approvals, checks and changes; check out the projected state; review; seal your approval and land, both through `oxy` in a terminal with your own key. |
|
| 10 | + | - **Issues from the editor**: turn a selection or a TODO line into an issue that quotes the code and links to it. |
|
| 10 | 11 | - **References while typing**: `#` and `@` completion in the commit box and comments, and a hover on `#N`. |
|
| 11 | 12 | - **Notifications**: an unread count in the status bar and an inbox that opens proposals and issues in the editor. |
|
| 12 | 13 | - **Diffs in the editor**: expand a proposal to its files and open each in VS Code's diff editor (target against projected state), or View Changes for all of them at once, without checking out. |
|
| … | 6 unchanged lines |
modified docs/spec.md +4 −0
| … | 78 unchanged lines |
||
| 79 | 79 | ||
| 80 | 80 | 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. |
|
| 81 | 81 | ||
| 82 | + | ### 8.1 Issues from the editor |
|
| 83 | + | ||
| 84 | + | **New Issue from Selection** (editor context menu) opens an issue quoting the selected lines, or the current line when nothing is selected, under a permalink to them on the hub: to the bookmark's head when it has exactly one and the file is saved, otherwise to the bookmark, with a note that the lines may move. A `TODO`, `FIXME`, `XXX` or `HACK` line suggests the title, and after the issue is opened the extension offers to write its number into the line (`TODO(#12): …`). The new issue opens in the editor. |
|
| 85 | + | ||
| 82 | 86 | ## 9. Checks and status bar |
|
| 83 | 87 | ||
| 84 | 88 | A status bar item shows, for the current repository's current bookmark, the open proposal whose source it is and the state of its checks (✔ n passed, ✘ failing, … running). It subscribes to the hub's proposal event stream and refreshes on `check` events; clicking opens the proposal. |
|
| … | 22 unchanged lines |
modified package.json +13 −1
| … | 1 unchanged lines |
||
| 2 | 2 | "name": "oxygenhub-vscode", |
|
| 3 | 3 | "displayName": "Oxygenhub", |
|
| 4 | 4 | "description": "Proposals, reviews, issues and checks from an Oxygenhub, in the editor. Builds on the Oxygen extension.", |
|
| 5 | - | "version": "0.4.0", |
|
| 5 | + | "version": "0.5.0", |
|
| 6 | 6 | "publisher": "andymccall", |
|
| 7 | 7 | "license": "MIT OR Apache-2.0", |
|
| 8 | 8 | "icon": "assets/images/oxygenhub.png", |
|
| … | 166 unchanged lines |
||
| 175 | 175 | "icon": "$(add)" |
|
| 176 | 176 | }, |
|
| 177 | 177 | { |
|
| 178 | + | "command": "oxygenhub.issueFromSelection", |
|
| 179 | + | "title": "New Issue from Selection\u2026", |
|
| 180 | + | "category": "Oxygenhub" |
|
| 181 | + | }, |
|
| 182 | + | { |
|
| 178 | 183 | "command": "oxygenhub.openIssue", |
|
| 179 | 184 | "title": "Open Issue", |
|
| 180 | 185 | "category": "Oxygenhub" |
|
| … | 138 unchanged lines |
||
| 319 | 324 | "command": "oxygenhub.openProposalFile", |
|
| 320 | 325 | "when": "false" |
|
| 321 | 326 | } |
|
| 327 | + | ], |
|
| 328 | + | "editor/context": [ |
|
| 329 | + | { |
|
| 330 | + | "command": "oxygenhub.issueFromSelection", |
|
| 331 | + | "when": "resourceScheme == file", |
|
| 332 | + | "group": "9_oxygenhub@1" |
|
| 333 | + | } |
|
| 322 | 334 | ] |
|
| 323 | 335 | } |
|
| 324 | 336 | }, |
|
| … | 18 unchanged lines |
modified src/extension.ts +57 −0
| … | 7 unchanged lines |
||
| 8 | 8 | import { hubUrl } from "./hub"; |
|
| 9 | 9 | import { InboxBar } from "./inbox"; |
|
| 10 | 10 | import { ReferenceCompletions, ReferenceHovers, ReferenceIndex, SELECTOR } from "./references"; |
|
| 11 | + | import { annotateTodo, issueBody, todoTitle } from "./refs"; |
|
| 11 | 12 | import { IssueNode, IssuesProvider, showIssue } from "./issues"; |
|
| 12 | 13 | import type { OxygenAPI } from "./oxygenapi"; |
|
| 13 | 14 | import { HubRepo, ProposalFileNode, ProposalNode, ProposalsProvider, showDetail } from "./proposals"; |
|
| … | 110 unchanged lines |
||
| 124 | 125 | ), |
|
| 125 | 126 | vscode.commands.registerCommand("oxygenhub.createProposal", () => createProposal()), |
|
| 126 | 127 | vscode.commands.registerCommand("oxygenhub.newIssue", () => newIssue()), |
|
| 128 | + | vscode.commands.registerCommand("oxygenhub.issueFromSelection", () => issueFromSelection()), |
|
| 127 | 129 | vscode.commands.registerCommand("oxygenhub.openIssue", async (node?: IssueNode) => node && showIssue(auth, node.repo, node.i)), |
|
| 128 | 130 | vscode.commands.registerCommand("oxygenhub.commentIssue", (...args: unknown[]) => |
|
| 129 | 131 | withIssue(args, async (repo, number) => { |
|
| … | 207 unchanged lines |
||
| 337 | 339 | } |
|
| 338 | 340 | } |
|
| 339 | 341 | ||
| 342 | + | /** |
|
| 343 | + | * An issue from the editor (spec §8.1): the selection, or the current line, |
|
| 344 | + | * quoted under a permalink to it on the hub. A TODO line suggests the title |
|
| 345 | + | * and afterwards records the issue's number. |
|
| 346 | + | */ |
|
| 347 | + | async function issueFromSelection(): Promise<void> { |
|
| 348 | + | const editor = vscode.window.activeTextEditor; |
|
| 349 | + | if (!editor || editor.document.uri.scheme !== "file") { |
|
| 350 | + | void vscode.window.showWarningMessage("Oxygenhub: open a file of the repository first."); |
|
| 351 | + | return; |
|
| 352 | + | } |
|
| 353 | + | const doc = editor.document; |
|
| 354 | + | const file = doc.uri.fsPath; |
|
| 355 | + | const repo = (await proposals.repos()).filter((r) => file.startsWith(r.handle.root + "/") || file.startsWith(r.handle.root + "\\")).sort((a, b) => b.handle.root.length - a.handle.root.length)[0]; |
|
| 356 | + | if (!repo) { |
|
| 357 | + | void vscode.window.showWarningMessage("Oxygenhub: this file is not in a repository with a hub remote."); |
|
| 358 | + | return; |
|
| 359 | + | } |
|
| 360 | + | const sel = editor.selection; |
|
| 361 | + | const startLine = sel.start.line; |
|
| 362 | + | const endLine = sel.isEmpty ? sel.start.line : sel.end.character === 0 && sel.end.line > sel.start.line ? sel.end.line - 1 : sel.end.line; |
|
| 363 | + | const code = doc.getText(new vscode.Range(startLine, 0, endLine, doc.lineAt(endLine).text.length)); |
|
| 364 | + | const todoLine = [...Array(endLine - startLine + 1).keys()].map((i) => startLine + i).find((l) => todoTitle(doc.lineAt(l).text) !== undefined); |
|
| 365 | + | const title = await vscode.window.showInputBox({ prompt: "Issue title", value: todoLine !== undefined ? todoTitle(doc.lineAt(todoLine).text) : "" }); |
|
| 366 | + | if (!title) { |
|
| 367 | + | return; |
|
| 368 | + | } |
|
| 369 | + | // Pin the link to the head when the bookmark has one; otherwise follow the bookmark. |
|
| 370 | + | const bookmark = repo.handle.bookmarks.find((b) => b.name === repo.handle.currentBookmark); |
|
| 371 | + | const pinned = bookmark?.heads.length === 1 && !doc.isDirty; |
|
| 372 | + | const reference = pinned ? bookmark!.heads[0] : (repo.handle.currentBookmark ?? "main"); |
|
| 373 | + | const path = file.slice(repo.handle.root.length + 1).split("\\").join("/"); |
|
| 374 | + | const permalink = hubUrl(repo.base, repo.owner, repo.name, { kind: "file", reference, path, line: startLine + 1, endLine: endLine + 1 }); |
|
| 375 | + | const body = issueBody({ permalink, path, startLine: startLine + 1, endLine: endLine + 1, language: doc.languageId === "plaintext" ? "" : doc.languageId, code, pinned }); |
|
| 376 | + | try { |
|
| 377 | + | const client = auth.clientFor(repo.base); |
|
| 378 | + | const issue = await client.createIssue(repo.owner, repo.name, title, body); |
|
| 379 | + | issues.refresh(); |
|
| 380 | + | references.clear(); |
|
| 381 | + | if (todoLine !== undefined) { |
|
| 382 | + | const line = doc.lineAt(todoLine); |
|
| 383 | + | const annotated = annotateTodo(line.text, issue.number); |
|
| 384 | + | if (annotated !== line.text) { |
|
| 385 | + | const pick = await vscode.window.showInformationMessage(`Oxygenhub: opened #${issue.number}. Record it in the TODO?`, "Add (#" + issue.number + ")"); |
|
| 386 | + | if (pick) { |
|
| 387 | + | await editor.edit((e) => e.replace(line.range, annotated)); |
|
| 388 | + | } |
|
| 389 | + | } |
|
| 390 | + | } |
|
| 391 | + | await showIssue(auth, repo, issue); |
|
| 392 | + | } catch (e) { |
|
| 393 | + | void vscode.window.showErrorMessage(`Oxygenhub: ${(e as Error).message}`); |
|
| 394 | + | } |
|
| 395 | + | } |
|
| 396 | + | ||
| 340 | 397 | export function deactivate(): void {} |
modified src/hub.ts +2 −1
| … | 189 unchanged lines |
||
| 190 | 190 | case "change": |
|
| 191 | 191 | return `${repo}/change/${target.hash}`; |
|
| 192 | 192 | case "file": { |
|
| 193 | - | const line = typeof target.line === "number" ? `#L${target.line}` : ""; |
|
| 193 | + | const end = typeof target.endLine === "number" && target.endLine !== target.line ? `-L${target.endLine}` : ""; |
|
| 194 | + | const line = typeof target.line === "number" ? `#L${target.line}${end}` : ""; |
|
| 194 | 195 | return `${repo}/file/${encodeURIComponent(String(target.reference))}/${String(target.path)}${line}`; |
|
| 195 | 196 | } |
|
| 196 | 197 | case "proposals": |
|
| … | 61 unchanged lines |
modified src/refs.ts +23 −0
| … | 46 unchanged lines |
||
| 47 | 47 | return undefined; |
|
| 48 | 48 | } |
|
| 49 | 49 | } |
|
| 50 | + | ||
| 51 | + | /** The note of a `TODO`/`FIXME`/`XXX`/`HACK` comment line, as an issue title. */ |
|
| 52 | + | export function todoTitle(line: string): string | undefined { |
|
| 53 | + | const m = /\b(TODO|FIXME|XXX|HACK)\b(?:\([^)]*\))?[:\s-]*(.*)$/.exec(line); |
|
| 54 | + | if (!m) { |
|
| 55 | + | return undefined; |
|
| 56 | + | } |
|
| 57 | + | const text = m[2].replace(/\s*(\*\/|-->|#\}|\*\)|;)\s*$/, "").trim(); |
|
| 58 | + | return text ? text : `${m[1]} in the code`; |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /** The TODO line with the new issue's number recorded: `TODO(#12): …`. */ |
|
| 62 | + | export function annotateTodo(line: string, number: number): string { |
|
| 63 | + | return line.replace(/\b(TODO|FIXME|XXX|HACK)\b(?!\()/, `$1(#${number})`); |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | /** An issue body quoting the code, with a permalink to it on the hub. */ |
|
| 67 | + | export function issueBody(opts: { permalink: string; path: string; startLine: number; endLine: number; language: string; code: string; pinned: boolean }): string { |
|
| 68 | + | const where = opts.startLine === opts.endLine ? `line ${opts.startLine}` : `lines ${opts.startLine}–${opts.endLine}`; |
|
| 69 | + | const fence = opts.code.includes("```") ? "~~~" : "```"; |
|
| 70 | + | const note = opts.pinned ? "" : "\n\n_The link follows the bookmark, which has several heads, so the lines may move._"; |
|
| 71 | + | return `From [\`${opts.path}\`, ${where}](${opts.permalink}):\n\n${fence}${opts.language}\n${opts.code.replace(/\n$/, "")}\n${fence}${note}\n`; |
|
| 72 | + | } |
modified test/provider.test.mjs +6 −0
| … | 31 unchanged lines |
||
| 32 | 32 | assert.equal(needsMe({ ...base, approvals: [{ account: "andy", sealed: false, key: null }] }, "andy"), true); |
|
| 33 | 33 | assert.equal(needsMe({ ...base, state: "landed" }, "andy"), false); |
|
| 34 | 34 | }); |
|
| 35 | + | ||
| 36 | + | import { hubUrl as hubUrl2 } from "../out/hub.mjs"; |
|
| 37 | + | test("file links carry a line range", () => { |
|
| 38 | + | assert.equal(hubUrl2("https://h", "a", "b", { kind: "file", reference: "main", path: "src/x.rs", line: 3, endLine: 7 }), "https://h/a/b/file/main/src/x.rs#L3-L7"); |
|
| 39 | + | assert.equal(hubUrl2("https://h", "a", "b", { kind: "file", reference: "main", path: "x", line: 3, endLine: 3 }), "https://h/a/b/file/main/x#L3"); |
|
| 40 | + | }); |
modified test/refs.test.mjs +19 −0
| … | 28 unchanged lines |
||
| 29 | 29 | assert.equal(rootFromScmQuery("rootUri=" + encodeURIComponent("file:///home/a%20b/x")), "/home/a b/x"); |
|
| 30 | 30 | assert.equal(rootFromScmQuery(""), undefined); |
|
| 31 | 31 | }); |
|
| 32 | + | ||
| 33 | + | import { todoTitle, annotateTodo, issueBody } from "../out/refs.mjs"; |
|
| 34 | + | ||
| 35 | + | test("TODO lines become issue titles and then carry the number", () => { |
|
| 36 | + | assert.equal(todoTitle(" // TODO: handle the empty well"), "handle the empty well"); |
|
| 37 | + | assert.equal(todoTitle("/* FIXME - leaks a buffer */"), "leaks a buffer"); |
|
| 38 | + | assert.equal(todoTitle("# XXX"), "XXX in the code"); |
|
| 39 | + | assert.equal(todoTitle("let x = 1;"), undefined); |
|
| 40 | + | assert.equal(annotateTodo(" // TODO: handle the empty well", 12), " // TODO(#12): handle the empty well"); |
|
| 41 | + | assert.equal(annotateTodo("// TODO(#3): already linked", 12), "// TODO(#3): already linked", "an existing link is kept"); |
|
| 42 | + | }); |
|
| 43 | + | ||
| 44 | + | test("the issue body quotes the code under a permalink", () => { |
|
| 45 | + | const body = issueBody({ permalink: "https://h/a/b/file/abc/src/x.rs#L3-L4", path: "src/x.rs", startLine: 3, endLine: 4, language: "rust", code: "fn a() {}\nfn b() {}\n", pinned: true }); |
|
| 46 | + | assert.ok(body.startsWith("From [`src/x.rs`, lines 3–4](https://h/a/b/file/abc/src/x.rs#L3-L4)"), body); |
|
| 47 | + | assert.ok(body.includes("```rust\nfn a() {}\nfn b() {}\n```"), body); |
|
| 48 | + | assert.ok(!body.includes("several heads")); |
|
| 49 | + | assert.ok(issueBody({ permalink: "p", path: "x", startLine: 1, endLine: 1, language: "", code: "a", pinned: false }).includes("several heads")); |
|
| 50 | + | }); |