OXYGENHUB

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