Implement the Gitea extension

The repository was a bare Nova scaffold. This builds it out to match the
capabilities of the official Gitea VS Code extension.

Sidebar sections:
  - Current Branch: pull requests and workflow runs for the checked-out
    branch, with a current/all/pinned branch filter
  - Workflows: runs grouped by workflow file, expanding into jobs, steps,
    and artifacts
  - Pull Requests: open pull requests across repositories, expanding into
    reviews, review comments, and changed files
  - Settings: per-instance connection state, plus repository Actions
    secrets and variables

Commands cover run control (re-run, re-run failed jobs, re-run a job,
cancel), job logs, artifact download/reveal/open, pull request overview,
diff, checkout, creation, merge and close, the full review cycle, and
secret and variable management. Multiple instances are supported, routed
by git remote host, with tokens held per instance in the Keychain.

Nova exposes no webview, diff editor, editor decorations, or extension
status bar, so four features are shaped differently from the VS Code
original: the pull request timeline renders as Markdown, diffs open as
unified .diff documents, review comments are published through an
IssueCollection so they appear in the gutter and the Issues sidebar, and
a failed run posts a notification. OAuth and insecureSkipVerify have no
Nova equivalent and are omitted. README.md records all of this.

Endpoints were taken from Gitea's published swagger.v1.json. Servers
predating the workflow runs API fall back to /actions/tasks.

Tests/ runs the extension's real code under Node against a stubbed Nova
runtime and a canned Gitea instance: 88 checks, no install step and no
network. Images are generated by Tools/make-icons.py.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MQuusXgZC2dzwpJJ1qhtti
This commit is contained in:
2026-08-28 19:55:54 -03:00
co-authored by Claude Opus 5
commit 694609a3cc
58 changed files with 6231 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# Tests
Two suites that run the extension's real code under plain Node. Nova's runtime
and a Gitea server are both stubbed, so there is nothing to install and nothing
to reach over the network.
```sh
Tests/run.sh # both suites
node Tests/units.test.js
node Tests/integration.test.js
```
Both exit non-zero on failure, so `Tests/run.sh` works as a CI step.
## What is here
| File | Purpose |
| --- | --- |
| `nova-stub.js` | Stands in for Nova's runtime: `nova.*`, `TreeView`, `TreeItem`, `Issue`, `IssueCollection`, `Process`, `Range`, `NotificationRequest`. Records messages, clipboard writes, and opened files on globals so assertions can inspect them. |
| `fake-gitea.js` | A canned Gitea 1.27 instance served through a `fetch()` stub — three workflow runs, two pull requests, reviews with a line comment, artifacts, secrets, and variables. Records every request it answers. |
| `assert.js` | The `Checker` used by both suites. |
| `units.test.js` | Remote URL parsing, diff-hunk arithmetic, per-file diff extraction, time formatting, preference precedence, HTTP error messages, the pre-1.24 `/actions/tasks` fallback, pagination and query building, and the no-token path. |
| `integration.test.js` | Activates the extension against a real throwaway git checkout, renders all four sidebar sections, and drives the commands that write files or call the API. |
`integration.test.js` creates its git fixture in a temporary directory and
removes it afterwards; nothing is written inside the repository.
## Adding a case
Fixtures live in `fake-gitea.js` — add to the exported data and, if the route is
new, to the router at the bottom of that file. Assertions use
`t.check(label, condition, detail)` or `t.equals(label, actual, expected)`;
`detail` is printed only when the check fails.
If a check fails on a value you believe is correct, confirm the expectation by
hand before changing the extension — the diff-hunk line arithmetic in
particular is easy to get wrong in a test.
+46
View File
@@ -0,0 +1,46 @@
//
// Tiny assertion helpers shared by the suites. Each suite owns a Checker; the
// process exit code comes from its failure count.
//
class Checker {
constructor() {
this.failures = 0;
}
/** Records a pass or failure. `detail` is printed only when it fails. */
check(label, condition, detail) {
if (condition) {
console.log(` ok ${label}`);
return true;
}
this.failures += 1;
console.log(` FAIL ${label}${detail !== undefined ? " — " + detail : ""}`);
return false;
}
/** Deep-ish equality by JSON shape, which is enough for these fixtures. */
equals(label, actual, expected) {
return this.check(
label,
JSON.stringify(actual) === JSON.stringify(expected),
`got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}`,
);
}
section(title) {
console.log(`\n=== ${title} ===`);
}
/** Prints the tally and exits non-zero when anything failed. */
finish(name) {
if (this.failures === 0) {
console.log(`\nALL ${name} PASSED`);
process.exit(0);
}
console.log(`\n${this.failures} ${name} FAILED`);
process.exit(1);
}
}
module.exports = { Checker };
+206
View File
@@ -0,0 +1,206 @@
// A canned Gitea instance served through a fetch() stub.
const BASE = "https://gitea.test/api/v1";
const now = new Date();
const iso = (minutesAgo) => new Date(now.getTime() - minutesAgo * 60000).toISOString();
const RUNS = [
{ id: 101, run_number: 12, display_title: "Fix the flaky test", path: ".gitea/workflows/ci.yml",
event: "push", status: "completed", conclusion: "success", head_branch: "feature/login",
head_sha: "abc1234def", created_at: iso(30), started_at: iso(29), completed_at: iso(26),
html_url: "https://gitea.test/acme/widget/actions/runs/101", actor: { login: "alice" } },
{ id: 102, run_number: 13, display_title: "Add login form", path: ".gitea/workflows/ci.yml",
event: "pull_request", status: "running", conclusion: "", head_branch: "feature/login",
head_sha: "def5678abc", created_at: iso(4), started_at: iso(4), completed_at: null,
html_url: "https://gitea.test/acme/widget/actions/runs/102", actor: { login: "bob" } },
{ id: 103, run_number: 5, display_title: "Nightly", path: ".gitea/workflows/nightly.yml",
event: "schedule", status: "completed", conclusion: "failure", head_branch: "main",
head_sha: "999aaa111", created_at: iso(300), started_at: iso(300), completed_at: iso(295),
html_url: "https://gitea.test/acme/widget/actions/runs/103", actor: { login: "ci" } },
];
const JOBS = {
101: [{ id: 9001, name: "build", status: "completed", conclusion: "success",
started_at: iso(29), completed_at: iso(27), runner_name: "runner-1",
html_url: "https://gitea.test/acme/widget/actions/runs/101/jobs/0",
steps: [{ number: 1, name: "Checkout", status: "completed", conclusion: "success", started_at: iso(29), completed_at: iso(29) },
{ number: 2, name: "Test", status: "completed", conclusion: "success", started_at: iso(29), completed_at: iso(27) }] }],
102: [{ id: 9002, name: "build", status: "running", conclusion: "", started_at: iso(4),
completed_at: null, steps: [] }],
103: [{ id: 9003, name: "nightly", status: "completed", conclusion: "failure",
started_at: iso(300), completed_at: iso(295), steps: [] }],
};
const ARTIFACTS = {
101: [{ id: 555, name: "coverage", size_in_bytes: 20480, expired: false,
archive_download_url: BASE + "/repos/acme/widget/actions/artifacts/555/zip" }],
};
const DIFF_HUNK = [
"@@ -10,4 +10,7 @@ function login() {",
" const form = document.querySelector('form');",
"- form.submit();",
"+ if (!validate(form)) {",
"+ return;",
"+ }",
"+ form.submit();",
].join("\n");
const PULLS = [
{ number: 7, title: "Add login form", state: "open", draft: false, merged: false, mergeable: true,
user: { login: "bob" }, updated_at: iso(3), created_at: iso(120),
head: { ref: "feature/login", label: "acme:feature/login", sha: "def5678abc" },
base: { ref: "main", label: "acme:main" },
body: "Adds a login form and validates it before submit.",
html_url: "https://gitea.test/acme/widget/pulls/7" },
{ number: 6, title: "Bump deps", state: "open", draft: true, merged: false, mergeable: true,
user: { login: "alice" }, updated_at: iso(600), created_at: iso(900),
head: { ref: "chore/deps", label: "acme:chore/deps", sha: "111222333" },
base: { ref: "main", label: "acme:main" }, body: "",
html_url: "https://gitea.test/acme/widget/pulls/6" },
];
const REVIEWS = {
7: [{ id: 4001, state: "REQUEST_CHANGES", body: "Please guard the submit.", comments_count: 1,
user: { login: "alice" }, submitted_at: iso(10),
html_url: "https://gitea.test/acme/widget/pulls/7#pullrequestreview-4001" },
{ id: 4002, state: "APPROVED", body: "LGTM now", comments_count: 0,
user: { login: "carol" }, submitted_at: iso(2) }],
6: [],
};
const REVIEW_COMMENTS = {
4001: [{ id: 7001, body: "Guard this before submitting.", path: "src/login.js", position: 13,
pull_request_review_id: 4001, user: { login: "alice" }, created_at: iso(10),
diff_hunk: DIFF_HUNK, resolver: null,
html_url: "https://gitea.test/acme/widget/pulls/7#issuecomment-7001" }],
};
const FILES = {
7: [{ filename: "src/login.js", status: "modified", additions: 4, deletions: 1,
html_url: "https://gitea.test/acme/widget/pulls/7/files#src-login-js" },
{ filename: "src/new.js", status: "added", additions: 12, deletions: 0 }],
6: [{ filename: "package.json", status: "modified", additions: 2, deletions: 2 }],
};
const PR_DIFF = [
"diff --git a/src/login.js b/src/login.js",
"index 1111111..2222222 100644",
"--- a/src/login.js",
"+++ b/src/login.js",
DIFF_HUNK,
"diff --git a/src/new.js b/src/new.js",
"new file mode 100644",
"index 0000000..3333333",
"--- /dev/null",
"+++ b/src/new.js",
"@@ -0,0 +1,2 @@",
"+export function noop() {}",
"",
].join("\n");
const SECRETS = [{ name: "DEPLOY_KEY", created_at: iso(5000), description: "" }];
const VARIABLES = [{ name: "REGISTRY", data: "registry.example.com" }];
const calls = [];
function json(body, status = 200) {
const payload = JSON.stringify(body);
return {
ok: status < 400, status,
text: async () => payload,
json: async () => body,
arrayBuffer: async () => new TextEncoder().encode(payload).buffer,
};
}
function plain(body, status = 200) {
return {
ok: status < 400, status,
text: async () => body,
json: async () => JSON.parse(body),
arrayBuffer: async () => new TextEncoder().encode(body).buffer,
};
}
global.fetch = async (url, init) => {
const method = (init && init.method) || "GET";
const parsed = new URL(url);
const p = parsed.pathname.replace("/api/v1", "");
const q = parsed.searchParams;
calls.push(method + " " + p + (q.toString() ? "?" + q.toString() : ""));
const auth = init && init.headers && init.headers["Authorization"];
if (!auth) return json({ message: "token required" }, 401);
const page = Number(q.get("page") || 1);
const first = (list) => (page > 1 ? [] : list);
if (p === "/version") return json({ version: "1.27.2" });
if (p === "/user") return json({ login: "tester", id: 1 });
if (p === "/user/repos") return json(first([
{ name: "widget", full_name: "acme/widget", owner: { login: "acme" },
html_url: "https://gitea.test/acme/widget", default_branch: "main" }]));
const m = p.match(/^\/repos\/([^/]+)\/([^/]+)(\/.*)?$/);
if (!m) return json({ message: "no route for " + p }, 404);
const rest = m[3] || "";
if (rest === "") return json({ name: m[2], full_name: m[1] + "/" + m[2] });
if (rest === "/branches") return json(first([{ name: "main" }, { name: "feature/login" }]));
if (rest === "/actions/runs") {
let runs = RUNS;
if (q.get("branch")) runs = runs.filter((r) => r.head_branch === q.get("branch"));
return json({ total_count: runs.length, workflow_runs: runs });
}
if (rest === "/actions/workflows") {
return json({ workflows: [{ id: 1, name: "CI", path: ".gitea/workflows/ci.yml" }] });
}
let sub = rest.match(/^\/actions\/runs\/(\d+)\/jobs$/);
if (sub) return json({ jobs: JOBS[sub[1]] || [] });
sub = rest.match(/^\/actions\/runs\/(\d+)\/artifacts$/);
if (sub) return json({ artifacts: ARTIFACTS[sub[1]] || [] });
sub = rest.match(/^\/actions\/jobs\/(\d+)\/logs$/);
if (sub) return plain("Starting job " + sub[1] + "\nDone\n");
sub = rest.match(/^\/actions\/artifacts\/(\d+)\/zip$/);
if (sub) return plain("PKfake-zip-bytes");
if (/^\/actions\/runs\/\d+\/(rerun|rerun-failed-jobs|cancel)$/.test(rest)) return json(null, 204);
if (/^\/actions\/runs\/\d+\/jobs\/\d+\/rerun$/.test(rest)) return json(null, 204);
if (rest === "/actions/secrets") return json(first(SECRETS));
if (rest === "/actions/variables") return json(first(VARIABLES));
if (/^\/actions\/secrets\/[^/]+$/.test(rest)) return json(null, 204);
if (/^\/actions\/variables\/[^/]+$/.test(rest)) return json(null, 204);
if (rest === "/pulls" && method === "GET") return json(first(PULLS));
if (rest === "/pulls" && method === "POST") {
return json({ number: 8, html_url: "https://gitea.test/acme/widget/pulls/8" });
}
sub = rest.match(/^\/pulls\/(\d+)$/);
if (sub) return json(PULLS.find((x) => x.number === Number(sub[1])) || null);
sub = rest.match(/^\/pulls\/(\d+)\.diff$/);
if (sub) return plain(PR_DIFF);
sub = rest.match(/^\/pulls\/(\d+)\/files$/);
if (sub) return json(first(FILES[sub[1]] || []));
sub = rest.match(/^\/pulls\/(\d+)\/reviews$/);
if (sub && method === "GET") return json(first(REVIEWS[sub[1]] || []));
if (sub && method === "POST") return json({ id: 4003 });
sub = rest.match(/^\/pulls\/(\d+)\/reviews\/(\d+)\/comments$/);
if (sub) return json(REVIEW_COMMENTS[sub[2]] || []);
if (/^\/pulls\/\d+\/merge$/.test(rest)) return json(null, 204);
if (/^\/pulls\/\d+\/comments\/\d+\/replies$/.test(rest)) return json({ id: 7002 });
if (/^\/pulls\/comments\/\d+\/(un)?resolve$/.test(rest)) return json(null, 204);
sub = rest.match(/^\/issues\/(\d+)$/);
if (sub) return json({ number: Number(sub[1]), state: "closed" });
if (/^\/issues\/\d+\/timeline$/.test(rest)) {
return json(first([{ type: "comment", body: "Looks good", user: { login: "carol" }, created_at: iso(5) }]));
}
if (/^\/issues\/\d+\/comments$/.test(rest)) return json(first([]));
return json({ message: "no route for " + p }, 404);
};
module.exports = { calls, RUNS, PULLS, DIFF_HUNK, PR_DIFF };
+255
View File
@@ -0,0 +1,255 @@
//
// End-to-end: activates the real extension against a real git checkout and a
// canned Gitea instance, then renders every sidebar section and drives the
// commands that touch the filesystem or the API.
//
const { EXTENSION_ROOT } = require("./nova-stub.js");
const fake = require("./fake-gitea.js");
const { Checker } = require("./assert.js");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const { execFileSync } = require("node:child_process");
const script = (...parts) => path.join(EXTENSION_ROOT, "Scripts", ...parts);
const t = new Checker();
// -- fixture ---------------------------------------------------------------
const WORKSPACE = fs.mkdtempSync(path.join(os.tmpdir(), "gitea-nova-test-"));
const REPO = path.join(WORKSPACE, "widget");
function buildRepository() {
fs.mkdirSync(path.join(REPO, "src"), { recursive: true });
fs.writeFileSync(
path.join(REPO, "src", "login.js"),
Array.from({ length: 20 }, (_, i) => `// line ${i + 1}`).join("\n") + "\n",
);
fs.writeFileSync(path.join(REPO, "README.md"), "# widget\n");
const git = (...args) => execFileSync("git", args, { cwd: REPO, encoding: "utf-8" });
git("init", "-q", "-b", "main");
git("config", "user.email", "[email protected]");
git("config", "user.name", "Test");
git("remote", "add", "origin", "[email protected]:acme/widget.git");
git("add", "-A");
git("commit", "-qm", "initial");
git("checkout", "-qb", "feature/login");
}
buildRepository();
nova.workspace.path = WORKSPACE;
nova.config.set("gitea.baseUrl", "https://gitea.test");
nova.credentials.setPassword("unsupervised.giteanova", "https://gitea.test", "tok_test");
// -- helpers ---------------------------------------------------------------
/** Renders a provider's tree to indented text, expanding lazy rows as it goes. */
async function render(provider, node = null, depth = 0, lines = []) {
for (const child of await provider.getChildren(node)) {
const item = provider.getTreeItem(child);
const description = item.descriptiveText ? ` [${item.descriptiveText}]` : "";
lines.push(`${" ".repeat(depth)}${item.name}${description}`);
if (depth < 3 && (child.children || child.load)) {
await render(provider, child, depth + 1, lines);
}
}
return lines;
}
/** Every command name the manifest exposes to the user. */
function declaredCommands(manifest) {
const declared = new Set();
for (const list of Object.values(manifest.commands || {})) {
for (const entry of list) if (entry.command) declared.add(entry.command);
}
for (const sidebar of manifest.sidebars || []) {
for (const section of sidebar.sections || []) {
const entries = [...(section.headerCommands || []), ...(section.contextCommands || [])];
for (const entry of entries) if (entry.command) declared.add(entry.command);
}
}
const walk = (entry) => {
if (entry.command) declared.add(entry.command);
for (const child of entry.children || []) walk(child);
};
for (const entry of manifest.config || []) walk(entry);
return declared;
}
// -- suite -----------------------------------------------------------------
(async () => {
const main = require(script("main.js"));
main.activate();
await new Promise((resolve) => setTimeout(resolve, 400)); // let activation settle
const store = require(script("controllers", "selection.js")).store();
const views = Object.fromEntries(global.__treeViews.map((view) => [view.id, view]));
t.section("discovery");
console.log(
store.repos
.map((r) => `${r.fullName} @ ${r.baseUrl} branch=${r.branch}`)
.join("\n") || "(none)",
);
t.check("one repository discovered", store.repos.length === 1, `got ${store.repos.length}`);
t.check("routed to the matching instance", store.repos[0]?.baseUrl === "https://gitea.test");
t.check("branch read from git", store.repos[0]?.branch === "feature/login", store.repos[0]?.branch);
t.check(
"instance reported healthy",
store.instanceStatus.get("https://gitea.test")?.ok === true,
JSON.stringify(store.instanceStatus.get("https://gitea.test")),
);
for (const id of ["gitea.branch", "gitea.workflows", "gitea.pullRequests", "gitea.settings"]) {
t.section(id);
const lines = await render(views[id].dataProvider);
console.log(lines.join("\n"));
t.check(`${id} renders rows`, lines.length > 0);
t.check(
`${id} renders no error rows`,
!lines.some((line) => line.includes("⚠")),
lines.filter((line) => line.includes("⚠")).join(" | "),
);
}
t.section("branch filtering");
const branchLines = await render(views["gitea.branch"].dataProvider);
t.check("shows the branch's pull request", branchLines.some((l) => l.includes("#7 Add login form")));
t.check("hides another branch's pull request", !branchLines.some((l) => l.includes("#6 Bump deps")));
t.check("hides another branch's run", !branchLines.some((l) => l.includes("Nightly")));
t.section("workflow grouping");
const workflowLines = await render(views["gitea.workflows"].dataProvider);
t.check(
"grouped by workflow file",
workflowLines.some((l) => l.trim().startsWith("ci.yml")) &&
workflowLines.some((l) => l.trim().startsWith("nightly.yml")),
);
t.check("jobs load under a run", workflowLines.some((l) => l.includes("build")));
t.check("steps load under a job", workflowLines.some((l) => l.includes("Checkout")));
t.check("artifacts listed under a run", workflowLines.some((l) => l.includes("coverage")));
t.section("inline review comments");
const { ReviewCommentsController } = require(script("controllers", "reviewComments.js"));
const comments = new ReviewCommentsController(store);
await comments.reload({ force: true });
const annotated = [...comments.collection.map.entries()];
for (const [uri, issues] of annotated) {
console.log(uri);
for (const issue of issues) console.log(` line ${issue.line} [${issue.source}] ${issue.message}`);
}
t.check("exactly one file annotated", annotated.length === 1, `got ${annotated.length}`);
t.check("annotation lands on the commented file", annotated[0]?.[0].endsWith("/src/login.js"));
// The hunk adds three lines after one context line starting at new line 10,
// so the commented line — the hunk's last — is line 14.
t.check("line derived from the diff hunk", annotated[0]?.[1][0].line === 14, annotated[0]?.[1][0].line);
t.check("request-changes comment raised as an error", annotated[0]?.[1][0].severity === IssueSeverity.Error);
comments.dispose();
t.section("manifest and command registration");
const manifest = JSON.parse(fs.readFileSync(path.join(EXTENSION_ROOT, "extension.json"), "utf-8"));
const declared = declaredCommands(manifest);
const registered = global.__commands;
const missing = [...declared].filter((name) => !registered.has(name));
t.check("every command the manifest offers is registered", missing.length === 0, missing.join(", "));
// gitea.openReviewComment is deliberately absent: it is a TreeItem.command
// for double-click, never a menu entry.
const extra = [...registered.keys()].filter((name) => !declared.has(name));
t.equals("only the double-click handler is manifest-free", extra, ["gitea.openReviewComment"]);
t.section("command behaviour");
const branch = views["gitea.branch"];
const roots = await branch.dataProvider.getChildren(null);
const pullFolder = roots.find((n) => n.kind === "prFolder");
const pullNode = (await branch.dataProvider.getChildren(pullFolder)).find((n) => n.kind === "pr");
branch.selection = [pullNode];
await registered.get("gitea.copyUrl")(branch);
t.check(
"copy URL uses the pull request's own page",
global.__clipboard === "https://gitea.test/acme/widget/pulls/7",
global.__clipboard,
);
await registered.get("gitea.openPullRequestDiff")(branch);
t.check("diff saved into the repository", fs.existsSync(path.join(REPO, ".tmp", "gitea-diffs", "pr-7.diff")));
await registered.get("gitea.openPullRequestOverview")(branch);
const overview = global.__opened?.untitled?.content ?? "";
t.check("overview headed with the pull request title", overview.includes("# #7 Add login form"));
t.check("overview lists reviews", overview.includes("alice") && overview.includes("request_changes"));
t.check("overview lists changed files", overview.includes("src/login.js"));
const runsFolder = roots.find((n) => n.kind === "runsFolder");
const runNode = (await branch.dataProvider.getChildren(runsFolder)).find((n) => n.kind === "run");
const jobNode = (await branch.dataProvider.getChildren(runNode)).find((n) => n.kind === "job");
branch.selection = [jobNode];
await registered.get("gitea.viewJobLogs")(branch);
const logsDir = path.join(REPO, ".tmp", "gitea-logs");
const logFiles = fs.existsSync(logsDir) ? fs.readdirSync(logsDir) : [];
t.check("job log written into the repository", logFiles.length === 1, logFiles.join(", "));
const workflows = views["gitea.workflows"];
const ciGroup = (await workflows.dataProvider.getChildren(null)).find(
(n) => n.kind === "workflow" && n.name === "ci.yml",
);
const finishedRun = (await workflows.dataProvider.getChildren(ciGroup)).find(
(n) => n.data.run.id === 101,
);
const artifactsFolder = (await workflows.dataProvider.getChildren(finishedRun)).find(
(n) => n.kind === "artifactsFolder",
);
workflows.selection = [(await workflows.dataProvider.getChildren(artifactsFolder))[0]];
await registered.get("gitea.downloadArtifact")(workflows);
const zip = path.join(REPO, ".tmp", "gitea-artifacts", "acme", "widget", "101", "coverage.zip");
t.check("artifact downloaded to the configured path", fs.existsSync(zip), zip);
t.section("secrets and variables");
const settingsLines = await render(views["gitea.settings"].dataProvider);
console.log(settingsLines.join("\n"));
t.check("secret listed", settingsLines.some((l) => l.includes("DEPLOY_KEY")));
t.check("variable listed", settingsLines.some((l) => l.includes("REGISTRY")));
t.section("write operations");
global.__messages.length = 0;
workflows.selection = [finishedRun];
await registered.get("gitea.rerunRun")(workflows);
t.check(
"re-run reported as requested",
global.__messages.some(([kind, message]) => kind === "info" && message.includes("Re-run")),
JSON.stringify(global.__messages),
);
global.__messages.length = 0;
global.__inputAnswer = "Looks good to me";
branch.selection = [pullNode];
await registered.get("gitea.reviewApprove")(branch);
t.check(
"approval submitted",
global.__messages.some(([kind, message]) => kind === "info" && message.includes("Review submitted")),
JSON.stringify(global.__messages),
);
t.section("api paths exercised");
console.log([...new Set(fake.calls.map((call) => call.split("?")[0]))].sort().join("\n"));
main.deactivate();
fs.rmSync(WORKSPACE, { recursive: true, force: true });
t.finish("INTEGRATION CHECKS");
})().catch((error) => {
console.error("HARNESS ERROR", error);
fs.rmSync(WORKSPACE, { recursive: true, force: true });
process.exit(2);
});
+158
View File
@@ -0,0 +1,158 @@
// Minimal stand-in for Nova's extension runtime, enough to exercise the
// extension's logic under plain Node.
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const EXTENSION_ROOT = path.resolve(__dirname, "..");
const configStore = new Map();
const workspaceStore = new Map();
function makeConfig(store) {
return {
get(key) { return store.has(key) ? store.get(key) : null; },
set(key, value) { store.set(key, value); },
remove(key) { store.delete(key); },
onDidChange() { return { dispose() {} }; },
observe() { return { dispose() {} }; },
};
}
global.TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 };
global.TreeItem = class TreeItem {
constructor(name, state) { this.name = name; this.collapsibleState = state; }
};
global.IssueSeverity = { Error: 0, Warning: 1, Hint: 2, Info: 3 };
global.Issue = class Issue {};
global.IssueCollection = class IssueCollection {
constructor(name) { this.name = name; this.map = new Map(); }
set(uri, issues) { this.map.set(uri, issues); }
get(uri) { return this.map.get(uri); }
has(uri) { return this.map.has(uri); }
remove(uri) { this.map.delete(uri); }
clear() { this.map.clear(); }
dispose() { this.map.clear(); }
};
global.Range = class Range {
constructor(start, end) { this.start = start; this.end = end; }
};
global.NotificationRequest = class NotificationRequest {
constructor(id) { this.identifier = id; }
};
global.Process = class Process {
constructor(command, options) { this.command = command; this.options = options; }
onStdout(cb) { this._out = cb; }
onStderr(cb) { this._err = cb; }
onDidExit(cb) { this._exit = cb; }
start() {
const { spawnSync } = require("node:child_process");
const args = this.options.args || [];
const result = spawnSync(this.command, args, { cwd: this.options.cwd, encoding: "utf-8" });
if (result.stdout && this._out) this._out(result.stdout);
if (result.stderr && this._err) this._err(result.stderr);
setImmediate(() => this._exit(result.status === null ? -1 : result.status));
}
};
const commands = new Map();
const treeViews = [];
global.TreeView = class TreeView {
constructor(id, options) {
this.id = id;
this.dataProvider = options.dataProvider;
this.visible = true;
this.selection = [];
treeViews.push(this);
}
reload() { return Promise.resolve(); }
dispose() {}
};
const keychain = new Map();
global.nova = {
version: [11, 0, 0],
versionString: "11.0",
inDevMode: () => true,
extension: { path: EXTENSION_ROOT },
config: makeConfig(configStore),
clipboard: { writeText(text) { global.__clipboard = text; } },
credentials: {
getPassword(service, user) { return keychain.get(`${service}:${user}`) ?? null; },
setPassword(service, user, password) { keychain.set(`${service}:${user}`, password); },
removePassword(service, user) { keychain.delete(`${service}:${user}`); },
},
commands: {
register(name, handler) {
if (commands.has(name)) throw new Error(`duplicate command: ${name}`);
commands.set(name, handler);
return { dispose() {} };
},
invoke(name, ...args) {
const handler = commands.get(name);
if (!handler) return Promise.reject(new Error(`no such command: ${name}`));
return Promise.resolve(handler(...args));
},
},
subscriptions: { add() {}, remove() {} },
openConfig() {}, openURL(url) { global.__openedUrl = url; }, beep() {},
path: {
join: (...parts) => path.join(...parts),
dirname: (p) => path.dirname(p),
basename: (p) => path.basename(p),
normalize: (p) => path.normalize(p),
isAbsolute: (p) => path.isAbsolute(p),
expanduser: (p) => p.replace(/^~/, os.homedir()),
split: (p) => p.split("/").filter(Boolean),
splitext: (p) => [p.slice(0, p.lastIndexOf(".")), p.slice(p.lastIndexOf("."))],
},
fs: {
F_OK: 0, R_OK: 4, W_OK: 2, X_OK: 1,
access(p) { return fs.existsSync(p); },
stat(p) {
if (!fs.existsSync(p)) return null;
const s = fs.statSync(p);
return { size: s.size, isFile: () => s.isFile(), isDirectory: () => s.isDirectory() };
},
listdir(p) { return fs.readdirSync(p); },
mkdir(p) { fs.mkdirSync(p); },
remove(p) { fs.unlinkSync(p); },
reveal(p) { global.__revealed = p; },
open(p, mode) {
const binary = mode.includes("b");
const chunks = [];
return {
write(data) { chunks.push(binary ? Buffer.from(data) : Buffer.from(String(data), "utf-8")); },
close() { fs.writeFileSync(p, Buffer.concat(chunks)); },
};
},
},
workspace: {
path: null,
config: makeConfig(workspaceStore),
activeTextEditor: null,
textEditors: [],
openFile(p, options) { global.__opened = { path: p, options }; return Promise.resolve({}); },
openNewTextDocument(options) { global.__opened = { untitled: options }; return Promise.resolve({}); },
showInformativeMessage(m) { global.__messages.push(["info", m]); },
showWarningMessage(m) { global.__messages.push(["warn", m]); },
showErrorMessage(m) { global.__messages.push(["error", m]); },
showActionPanel(m, o, cb) { global.__messages.push(["action", m]); cb(global.__actionAnswer ?? null); },
showInputPanel(m, o, cb) { global.__messages.push(["input", m]); cb(global.__inputAnswer ?? null); },
showInputPalette(m, o, cb) { cb(global.__inputAnswer ?? null); },
showChoicePalette(items, o, cb) {
const i = global.__choiceIndex ?? 0;
cb(items[i] ?? null, items[i] === undefined ? null : i);
},
},
notifications: { add() { return Promise.resolve({ actionIdx: 2 }); } },
};
global.__messages = [];
global.__commands = commands;
global.__treeViews = treeViews;
global.__keychain = keychain;
module.exports = { EXTENSION_ROOT };
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
#
# Runs both suites. Needs only node and git on PATH — no npm install, no
# network: the Gitea instance is a fetch() stub and Nova's runtime is stubbed.
#
set -e
cd "$(dirname "$0")"
status=0
for suite in units.test.js integration.test.js; do
printf '\n########## %s ##########\n' "$suite"
node "$suite" || status=1
done
printf '\n'
if [ "$status" -eq 0 ]; then
echo "All suites passed."
else
echo "One or more suites failed."
fi
exit "$status"
+256
View File
@@ -0,0 +1,256 @@
//
// Units and edge cases: remote URL parsing, diff arithmetic, time formatting,
// preference precedence, HTTP error surfacing, pagination, and the unconfigured
// and legacy-server paths.
//
const { EXTENSION_ROOT } = require("./nova-stub.js");
const { PR_DIFF } = require("./fake-gitea.js");
const { Checker } = require("./assert.js");
const path = require("node:path");
const script = (...parts) => path.join(EXTENSION_ROOT, "Scripts", ...parts);
const t = new Checker();
/** Swaps in a fetch implementation for the duration of `body`. */
async function withResponder(responder, body) {
const saved = global.fetch;
global.fetch = responder;
try {
return await body();
} finally {
global.fetch = saved;
}
}
const ok = (payload) => ({ ok: true, status: 200, text: async () => JSON.stringify(payload) });
const fail = (status, message) => ({
ok: false,
status,
text: async () => JSON.stringify({ message }),
});
(async () => {
t.section("remote URL parsing");
const remotes = require(script("gitea", "remotes.js"));
const parsed = (host, owner, name, port = null) => ({ host, port, owner, name });
t.equals("https", remotes.parse("https://gitea.com/acme/widget.git"), parsed("gitea.com", "acme", "widget"));
t.equals("https without .git", remotes.parse("https://gitea.com/acme/widget"), parsed("gitea.com", "acme", "widget"));
t.equals(
"https with credentials",
remotes.parse("https://user:[email protected]/acme/widget.git"),
parsed("git.example.com", "acme", "widget"),
);
t.equals(
"https with port",
remotes.parse("https://git.example.com:3000/acme/widget.git"),
parsed("git.example.com", "acme", "widget", 3000),
);
t.equals("scp-style ssh", remotes.parse("[email protected]:acme/widget.git"), parsed("gitea.com", "acme", "widget"));
t.equals(
"ssh:// with port",
remotes.parse("ssh://[email protected]:2222/acme/widget.git"),
parsed("git.example.com", "acme", "widget", 2222),
);
t.equals(
"instance served from a sub-path",
remotes.parse("https://example.com/gitea/acme/widget.git"),
parsed("example.com", "acme", "widget"),
);
t.equals("host lower-cased", remotes.parse("[email protected]:Acme/Widget.git").host, "gitea.com");
t.equals("garbage rejected", remotes.parse("not a url"), null);
t.equals("empty rejected", remotes.parse(""), null);
t.equals("hostOf drops the port", remotes.hostOf("https://git.example.com:3000"), "git.example.com");
t.equals("hostOf on a plain URL", remotes.hostOf("https://gitea.com"), "gitea.com");
t.section("diff hunk to file line");
const review = require(script("controllers", "review.js"));
t.equals("additions only", review.lineFromDiffHunk("@@ -0,0 +1,2 @@\n+first\n+second", null), 2);
t.equals("removed lines do not advance the new file", review.lineFromDiffHunk("@@ -10,4 +10,7 @@\n ctx\n-gone\n+a\n+b", null), 12);
t.equals("single-line hunk header", review.lineFromDiffHunk("@@ -5 +5 @@\n+changed", null), 5);
t.equals("no hunk falls back to position", review.lineFromDiffHunk(null, 42), 42);
t.equals("unparseable hunk falls back", review.lineFromDiffHunk("no header here", 7), 7);
t.section("per-file diff extraction");
const pulls = require(script("controllers", "pullRequests.js"));
const first = pulls.extractFileDiff(PR_DIFF, "src/login.js");
t.check(
"slices out only the first file",
first.startsWith("diff --git a/src/login.js") && !first.includes("src/new.js"),
JSON.stringify(first?.slice(0, 60)),
);
const last = pulls.extractFileDiff(PR_DIFF, "src/new.js");
t.check("slices out the last file", last.startsWith("diff --git a/src/new.js") && last.includes("noop"));
t.equals("unknown file yields null", pulls.extractFileDiff(PR_DIFF, "nope.js"), null);
t.section("time formatting");
const time = require(script("util", "time.js"));
t.equals("Go zero time renders blank", time.relative("0001-01-01T00:00:00Z"), "");
t.equals("null renders blank", time.relative(null), "");
t.check("a past time reads as ago", time.relative(new Date(Date.now() - 7200e3).toISOString()).endsWith("ago"));
t.equals("minutes and seconds", time.duration("2026-08-28T10:00:00Z", "2026-08-28T10:01:23Z"), "1m 23s");
t.equals("hours and minutes", time.duration("2026-08-28T10:00:00Z", "2026-08-28T12:30:00Z"), "2h 30m");
t.check(
"a zero end time still yields a duration",
typeof time.duration("2026-08-28T10:00:00Z", "0001-01-01T00:00:00Z") === "string",
);
t.section("preference precedence");
const config = require(script("util", "config.js"));
nova.config.set("gitea.baseUrl", "https://global.example.com");
t.equals("global value used", config.get("gitea.baseUrl"), "https://global.example.com");
nova.workspace.config.set("gitea.baseUrl", "https://workspace.example.com");
t.equals("workspace overrides global", config.get("gitea.baseUrl"), "https://workspace.example.com");
nova.workspace.config.remove("gitea.baseUrl");
t.equals("falls back to global", config.get("gitea.baseUrl"), "https://global.example.com");
nova.config.remove("gitea.baseUrl");
t.equals("falls back to the built-in default", config.get("gitea.baseUrl"), "https://gitea.com");
t.equals("boolean default when unset", config.bool("gitea.reviewComments.enabled"), true);
nova.config.set("gitea.reviewComments.enabled", false);
t.equals("an explicit false is respected", config.bool("gitea.reviewComments.enabled"), false);
nova.config.remove("gitea.reviewComments.enabled");
t.equals("trailing slashes stripped", config.normalizeBaseUrl("https://gitea.com///"), "https://gitea.com");
t.equals("missing scheme filled in", config.normalizeBaseUrl("gitea.example.com"), "https://gitea.example.com");
t.equals("blank rejected", config.normalizeBaseUrl(" "), null);
nova.config.set("gitea.baseUrl", "https://a.example.com");
nova.config.set("gitea.instances", ["https://b.example.com/", "https://a.example.com", "https://b.example.com"]);
t.equals("instances de-duplicated, default first", config.instances(), [
"https://a.example.com",
"https://b.example.com",
]);
t.section("HTTP error surfacing");
const { GiteaClient, GiteaError } = require(script("gitea", "client.js"));
const { GiteaApi } = require(script("gitea", "api.js"));
const api = new GiteaApi(new GiteaClient("https://gitea.test", () => "tok"));
const repo = { owner: "acme", name: "widget" };
await withResponder(async () => fail(401, "token is invalid"), async () => {
try {
await api.currentUser();
t.check("401 throws", false);
} catch (error) {
t.check("401 raises a GiteaError", error instanceof GiteaError);
t.check("401 flagged as an auth failure", error.isAuth === true);
t.check("401 tells the user what to do", error.message.includes("Set a valid token"), error.message);
}
});
await withResponder(async () => fail(403, "missing scope"), async () => {
try {
await api.listSecrets(repo);
t.check("403 throws", false);
} catch (error) {
t.check("403 names the cause", error.message.includes("scope"), error.message);
t.check("403 keeps the server's own detail", error.message.includes("missing scope"), error.message);
}
});
t.section("older Gitea servers");
await withResponder(
async (url) => {
if (url.includes("/actions/runs")) return fail(404, "Not Found");
if (url.includes("/actions/tasks")) {
return ok({ total_count: 1, workflow_runs: [{ id: 1, name: "legacy" }] });
}
return fail(404, "Not Found");
},
async () => {
const result = await api.listRuns(repo, { limit: 10 });
t.check(
"runs fall back to /actions/tasks",
result.legacy === true && result.runs.length === 1,
JSON.stringify(result),
);
},
);
await withResponder(async () => fail(404, "Not Found"), async () => {
t.equals("a missing artifacts endpoint yields an empty list", await api.listArtifacts(repo, 1), []);
});
t.section("query building and pagination");
const paged = [];
await withResponder(
async (url) => {
paged.push(url);
const page = Number(new URL(url).searchParams.get("page"));
return ok(
page === 1
? Array.from({ length: 50 }, (_, i) => ({ number: i + 1 }))
: Array.from({ length: 5 }, (_, i) => ({ number: 51 + i })),
);
},
async () => {
t.equals("collects across pages", (await api.listPullRequests(repo, { limit: 60 })).length, 55);
t.check("page and limit sent", paged[0].includes("page=1") && paged[0].includes("limit=50"), paged[0]);
t.check(
"state and sort sent",
paged[0].includes("state=open") && paged[0].includes("sort=recentupdate"),
paged[0],
);
t.check("stops after a short page", paged.length === 2, String(paged.length));
},
);
const built = [];
await withResponder(
async (url) => {
built.push(url);
return ok({ workflow_runs: [] });
},
async () => {
await api.listRuns(repo, { limit: 20, branch: null, event: null });
t.check(
"null query parameters omitted entirely",
!built[0].includes("branch=") && !built[0].includes("event=") && built[0].includes("limit=20"),
built[0],
);
},
);
const escaped = [];
await withResponder(
async (url) => {
escaped.push(url);
return ok({});
},
async () => {
await api.repository({ owner: "my org", name: "a+b" });
t.check("path segments percent-encoded", escaped[0].includes("/repos/my%20org/a%2Bb"), escaped[0]);
},
);
t.section("nothing configured");
const { Store } = require(script("store.js"));
nova.config.set("gitea.baseUrl", "https://untokened.example.com");
nova.config.set("gitea.instances", []);
const store = new Store();
t.check("store reports no token", store.hasAnyToken === false);
await store.refresh({ rediscover: true });
store.stop();
const status = store.instanceStatus.get("https://untokened.example.com");
t.check("instance marked as needing a token", status?.needsToken === true, JSON.stringify(status));
const { BranchProvider } = require(script("views", "branchView.js"));
const rows = await new BranchProvider(store).getChildren(null);
t.check(
"the sidebar explains how to sign in",
rows.length === 1 && rows[0].name.includes("Set Token"),
rows.map((row) => row.name).join(" | "),
);
store.dispose();
t.finish("UNIT CHECKS");
})().catch((error) => {
console.error("HARNESS ERROR", error);
process.exit(2);
});