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
+139
View File
@@ -0,0 +1,139 @@
//
// Workflow artifacts: download to disk, reveal in Finder, and open. Artifacts
// arrive as zip archives, so "open" unzips alongside the download when possible
// and otherwise hands the archive to Finder.
//
const builders = require("../views/builders.js");
const config = require("../util/config.js");
const selection = require("./selection.js");
const ui = require("../ui.js");
const { exec } = require("../util/exec.js");
/** Resolves the configured download directory for a repository. */
function downloadDirectory(repo, run) {
const configured = config.artifactDownloadPath();
const base = nova.path.isAbsolute(configured)
? configured
: nova.path.join(repo.dir || nova.workspace.path || nova.path.expanduser("~"), configured);
return nova.path.join(base, repo.owner, repo.name, String(run ? run.id : "runs"));
}
function artifactFrom(argument) {
const node = selection.selectedNode(argument);
if (!node || !node.data || !node.data.artifact) return null;
return {
node: node,
repo: selection.repoOf(node),
artifact: node.data.artifact,
run: node.data.run,
};
}
async function download(store, repo, run, artifact) {
if (artifact.expired) {
ui.warn(`${artifact.name} has expired and is no longer downloadable.`);
return null;
}
const api = store.apiFor(repo);
if (!api) return null;
const target = nova.path.join(downloadDirectory(repo, run), `${artifact.name}.zip`);
try {
const bytes = await api.downloadArtifact(repo, artifact);
ui.writeBinary(target, bytes);
} catch (error) {
ui.error(
`Could not download ${artifact.name}: ${error.message || error}` +
(error.status === 403
? " The token needs the Actions read scope for artifacts."
: ""),
);
return null;
}
const size = builders.formatBytes(
artifact.size_in_bytes || (nova.fs.stat(target) || {}).size || 0,
);
ui.info(`Downloaded ${artifact.name} (${size}) to ${target}.`);
return target;
}
/** Unzips into a sibling folder; returns the folder or null when unzip fails. */
async function unzip(zipPath) {
const folder = zipPath.replace(/\.zip$/i, "");
const result = await exec("/usr/bin/env", ["unzip", "-o", "-q", zipPath, "-d", folder]);
return result.status === 0 ? folder : null;
}
function register(store) {
nova.commands.register("gitea.downloadArtifact", async (argument) => {
const context = artifactFrom(argument);
if (!context || !context.repo) {
ui.warn("Select an artifact in the Gitea sidebar first.");
return;
}
const path = await download(store, context.repo, context.run, context.artifact);
if (path) nova.fs.reveal(path);
});
nova.commands.register("gitea.openArtifact", async (argument) => {
const context = artifactFrom(argument);
if (!context || !context.repo) {
ui.warn("Select an artifact in the Gitea sidebar first.");
return;
}
const zipPath = await download(store, context.repo, context.run, context.artifact);
if (!zipPath) return;
const folder = await unzip(zipPath);
if (!folder) {
nova.fs.reveal(zipPath);
return;
}
// A single-file artifact is far more useful opened than revealed.
let entries = [];
try {
entries = nova.fs.listdir(folder).filter((entry) => !entry.startsWith("."));
} catch (error) {
entries = [];
}
if (entries.length === 1) {
const only = nova.path.join(folder, entries[0]);
const stats = nova.fs.stat(only);
if (stats && stats.isFile()) {
await nova.workspace.openFile(only);
return;
}
}
nova.fs.reveal(folder);
});
nova.commands.register("gitea.revealArtifact", async (argument) => {
const context = artifactFrom(argument);
if (!context || !context.repo) {
ui.warn("Select an artifact in the Gitea sidebar first.");
return;
}
const expected = nova.path.join(
downloadDirectory(context.repo, context.run),
`${context.artifact.name}.zip`,
);
if (nova.fs.access(expected, nova.fs.F_OK)) {
nova.fs.reveal(expected);
return;
}
const path = await download(store, context.repo, context.run, context.artifact);
if (path) nova.fs.reveal(path);
});
}
exports.register = register;
exports.downloadDirectory = downloadDirectory;
+148
View File
@@ -0,0 +1,148 @@
//
// Instance and token management: sign in, sign out, test, add, remove.
//
const config = require("../util/config.js");
const credentials = require("../gitea/credentials.js");
const selection = require("./selection.js");
const ui = require("../ui.js");
const TOKEN_HELP =
"Create one in Gitea under Settings → Applications → Access Tokens with " +
"repository, issue, and Actions read scopes (plus write scopes for reviews, " +
"secrets, and variables).";
/** Picks the instance a command applies to, prompting only when ambiguous. */
async function resolveInstance(argument, prompt) {
const node = selection.selectedNode(argument);
if (node && node.data && node.data.baseUrl) return node.data.baseUrl;
const repo = node && node.repo ? node.repo : null;
if (repo) return repo.baseUrl;
const urls = config.instances();
if (!urls.length) {
ui.warn("No Gitea instance is configured. Open the extension preferences first.");
return null;
}
if (urls.length === 1) return urls[0];
return await ui.choose(urls, { placeholder: prompt });
}
function register(store, refresh) {
nova.commands.register("gitea.setToken", async (argument) => {
const baseUrl = await resolveInstance(argument, "Set a token for which instance?");
if (!baseUrl) return;
const token = await ui.input(`Personal access token for ${baseUrl}`, {
placeholder: "Access token",
prompt: TOKEN_HELP,
secure: true,
});
if (!token) return;
try {
credentials.setToken(baseUrl, token.trim());
} catch (error) {
ui.error(`Could not save the token to the Keychain: ${error.message || error}`);
return;
}
store.router.invalidate();
const api = store.router.apiFor(baseUrl);
try {
const [version, user] = await Promise.all([api.version(), api.currentUser()]);
ui.info(`Signed in to ${baseUrl} as ${user.login} (Gitea ${version}).`);
} catch (error) {
ui.error(`Token saved, but ${baseUrl} rejected it: ${error.message || error}`);
}
await refresh({ rediscover: true });
});
nova.commands.register("gitea.clearToken", async (argument) => {
const baseUrl = await resolveInstance(argument, "Sign out of which instance?");
if (!baseUrl) return;
if (!(await ui.confirm(`Remove the stored token for ${baseUrl}?`, "Sign Out"))) return;
credentials.removeToken(baseUrl);
store.router.invalidate();
ui.info(`Signed out of ${baseUrl}.`);
await refresh({ rediscover: true });
});
nova.commands.register("gitea.testConnection", async (argument) => {
const baseUrl = await resolveInstance(argument, "Test which instance?");
if (!baseUrl) return;
if (!credentials.hasToken(baseUrl)) {
ui.warn(`No token stored for ${baseUrl}. Run “Set Token…” first.`);
return;
}
const api = store.router.apiFor(baseUrl);
try {
const [version, user] = await Promise.all([api.version(), api.currentUser()]);
ui.info(`${baseUrl} is reachable — Gitea ${version}, signed in as ${user.login}.`);
} catch (error) {
ui.error(`${baseUrl} failed: ${error.message || error}`);
}
await refresh();
});
nova.commands.register("gitea.addInstance", async () => {
const entered = await ui.input("Add a Gitea instance", {
placeholder: "https://gitea.example.com",
prompt: "Repositories are routed to an instance by matching their git remote host.",
});
if (!entered) return;
const baseUrl = config.normalizeBaseUrl(entered);
if (!baseUrl) {
ui.warn("That does not look like a URL.");
return;
}
if (config.instances().includes(baseUrl)) {
ui.info(`${baseUrl} is already configured.`);
return;
}
const extra = nova.config.get("gitea.instances", "array") || [];
config.setInstances([...extra, baseUrl]);
store.router.invalidate();
if (await ui.confirm(`Added ${baseUrl}. Set a token for it now?`, "Set Token")) {
await nova.commands.invoke("gitea.setToken", { kind: "instance", data: { baseUrl } });
} else {
await refresh({ rediscover: true });
}
});
nova.commands.register("gitea.removeInstance", async (argument) => {
const extra = nova.config.get("gitea.instances", "array") || [];
if (!extra.length) {
ui.info("The default instance is set in preferences and cannot be removed here.");
return;
}
const node = selection.selectedNode(argument);
let baseUrl = node && node.data ? node.data.baseUrl : null;
if (!baseUrl || !extra.includes(baseUrl)) {
baseUrl = await ui.choose(extra, { placeholder: "Remove which instance?" });
}
if (!baseUrl) return;
if (!(await ui.confirm(`Remove ${baseUrl} and its stored token?`, "Remove"))) return;
credentials.removeToken(baseUrl);
config.setInstances(extra.filter((url) => url !== baseUrl));
store.router.invalidate();
await refresh({ rediscover: true });
});
nova.commands.register("gitea.openPreferences", () => {
nova.openConfig();
});
}
exports.register = register;
exports.resolveInstance = resolveInstance;
+71
View File
@@ -0,0 +1,71 @@
//
// "Open in Browser" and "Copy URL" for whatever is selected in the sidebar.
//
const selection = require("./selection.js");
const ui = require("../ui.js");
/** Best web URL for a node, walking up to its repository as a last resort. */
function urlFor(node) {
if (!node) return null;
const data = node.data || {};
// Most specific first: a job page beats its run's, a comment beats its
// pull request's. Anything without a page of its own falls back to the
// repository.
const direct =
(data.comment && data.comment.html_url) ||
(data.review && data.review.html_url) ||
(data.file && data.file.html_url) ||
(data.job && data.job.html_url) ||
(data.pull && data.pull.html_url) ||
(data.run && data.run.html_url) ||
null;
if (direct) return direct;
if (node.kind === "workflow") {
const repo = selection.repoOf(node);
return repo ? `${repo.htmlUrl}/actions?workflow=${encodeURIComponent(node.name)}` : null;
}
if (node.kind === "instance" && data.baseUrl) return data.baseUrl;
if (node.kind === "secretsFolder" || node.kind === "secret") {
const repo = selection.repoOf(node);
return repo ? `${repo.htmlUrl}/settings/actions/secrets` : null;
}
if (node.kind === "variablesFolder" || node.kind === "variable") {
const repo = selection.repoOf(node);
return repo ? `${repo.htmlUrl}/settings/actions/variables` : null;
}
return repoUrl(node);
}
function repoUrl(node) {
const repo = selection.repoOf(node);
return repo ? repo.htmlUrl : null;
}
function register() {
nova.commands.register("gitea.openInBrowser", (argument) => {
const url = urlFor(selection.selectedNode(argument));
if (!url) {
ui.warn("There is no web page for that item.");
return;
}
nova.openURL(url);
});
nova.commands.register("gitea.copyUrl", (argument) => {
const url = urlFor(selection.selectedNode(argument));
if (!url) {
ui.warn("There is no URL for that item.");
return;
}
nova.clipboard.writeText(url);
ui.info(`Copied ${url}`);
});
}
exports.register = register;
exports.urlFor = urlFor;
+131
View File
@@ -0,0 +1,131 @@
//
// Job logs. Nova has no output panel an extension can write to, so logs open in
// the editor — saved under .tmp/gitea-logs/ when the preference allows it.
//
const config = require("../util/config.js");
const nodes = require("../views/nodes.js");
const selection = require("./selection.js");
const ui = require("../ui.js");
const FAILED = new Set(["failure", "failed", "error", "timed_out"]);
function logPath(repo, run, job) {
if (!config.saveLogsToRepo() || !repo.dir) return null;
const safe = String(job.name || job.id).replace(/[^A-Za-z0-9._-]+/g, "-");
return nova.path.join(
repo.dir,
".tmp",
"gitea-logs",
`${run ? run.id : "run"}-${job.id}-${safe}.log`,
);
}
function header(repo, run, job) {
return [
`# ${repo.fullName}${job.name || `job ${job.id}`}`,
run ? `# Run: ${nodes.runTitle(run)} (#${run.run_number || run.id})` : null,
`# Status: ${nodes.runState(job)}`,
job.html_url ? `# ${job.html_url}` : null,
"",
"",
]
.filter((line) => line !== null)
.join("\n");
}
async function openJobLogs(store, repo, run, job) {
const api = store.apiFor(repo);
if (!api) return;
let text;
try {
text = await api.jobLogs(repo, job.id);
} catch (error) {
ui.error(`Could not fetch logs for ${job.name || job.id}: ${error.message || error}`);
return;
}
if (!text || !text.trim()) {
ui.info(`No logs are available yet for ${job.name || job.id}.`);
return;
}
await ui.openText(header(repo, run, job) + text, { path: logPath(repo, run, job) });
}
function register(store) {
nova.commands.register("gitea.viewJobLogs", async (argument) => {
const node = selection.selectedNode(argument);
if (!node || !node.data || !node.data.job) {
ui.warn("Select a job in the Gitea sidebar first.");
return;
}
const repo = selection.repoOf(node);
if (!repo) return;
await openJobLogs(store, repo, node.data.run, node.data.job);
});
nova.commands.register("gitea.openLatestFailedJobLogs", async (argument) => {
// Invoked from a failure notification with an explicit run, or from the
// menu, where the most recent failed run across all repositories wins.
let repo = null;
let run = null;
if (argument && argument.repoKey) {
repo = store.repoFor(argument.repoKey);
const entry = repo ? store.runsFor(repo.key) : null;
run = entry ? entry.runs.find((item) => String(item.id) === String(argument.runId)) : null;
}
if (!run) {
const node = selection.selectedNode(argument);
const nodeRun = selection.runFor(node);
if (nodeRun && FAILED.has(nodes.runState(nodeRun))) {
repo = selection.repoOf(node);
run = nodeRun;
}
}
if (!run) {
let newest = null;
for (const candidate of store.repos) {
for (const item of store.runsFor(candidate.key).runs) {
if (!FAILED.has(nodes.runState(item))) continue;
const at = new Date(item.started_at || item.created_at || 0).getTime();
if (!newest || at > newest.at) newest = { at: at, repo: candidate, run: item };
}
}
if (!newest) {
ui.info("No failed workflow runs were found.");
return;
}
repo = newest.repo;
run = newest.run;
}
const detail = await store.loadRunDetail(repo, run);
const failed = (detail ? detail.jobs : []).filter((job) =>
FAILED.has(nodes.runState(job)),
);
if (!failed.length) {
ui.info(`No failed jobs in ${nodes.runTitle(run)}.`);
return;
}
const job =
failed.length === 1
? failed[0]
: await ui.choose(failed, {
placeholder: "Open logs for which failed job?",
label: (item) => item.name || `Job ${item.id}`,
});
if (!job) return;
await openJobLogs(store, repo, run, job);
});
}
exports.register = register;
exports.openJobLogs = openJobLogs;
+336
View File
@@ -0,0 +1,336 @@
//
// Pull request lifecycle: overview, diff, changed files, checkout, create,
// merge, close. Nova has no webview or programmatic diff editor, so the
// overview renders as Markdown and diffs open as .diff documents.
//
const git = require("../util/git.js");
const nodes = require("../views/nodes.js");
const selection = require("./selection.js");
const time = require("../util/time.js");
const ui = require("../ui.js");
const MERGE_STRATEGIES = [
{ value: "merge", label: "Create a merge commit" },
{ value: "rebase", label: "Rebase then fast-forward" },
{ value: "rebase-merge", label: "Rebase then create a merge commit" },
{ value: "squash", label: "Squash and merge" },
];
async function contextFrom(argument, store) {
const node = selection.selectedNode(argument);
const pull = selection.pullRequestFor(node);
const repo = selection.repoOf(node);
if (pull && repo) return { node, repo, pull };
// Fall back to picking from the open pull requests of a chosen repository.
const chosen = repo || (await selection.resolveRepo(argument));
if (!chosen) return { node, repo: null, pull: null };
const items = store.pullRequestsFor(chosen.key).items;
if (!items.length) {
ui.info(`No open pull requests in ${chosen.fullName}.`);
return { node, repo: chosen, pull: null };
}
const picked =
items.length === 1
? items[0]
: await ui.choose(items, {
placeholder: "Choose a pull request",
label: (item) => `#${item.number} ${item.title}`,
});
return { node, repo: chosen, pull: picked };
}
function renderOverview(repo, pull, detail, timeline) {
const lines = [];
lines.push(`# #${pull.number} ${pull.title}`);
lines.push("");
const facts = [
`- **Repository:** ${repo.fullName}`,
`- **Author:** ${pull.user ? pull.user.login : "unknown"}`,
`- **State:** ${pull.merged ? "merged" : pull.state}${pull.draft ? " (draft)" : ""}`,
pull.head && pull.base ? `- **Branches:** \`${pull.head.ref}\`\`${pull.base.ref}\`` : null,
`- **Updated:** ${time.absolute(pull.updated_at)} (${time.relative(pull.updated_at)})`,
pull.mergeable === false ? "- **Conflicts:** this pull request cannot be merged cleanly" : null,
`- **URL:** ${pull.html_url}`,
].filter(Boolean);
lines.push(...facts, "");
if (pull.body && pull.body.trim()) {
lines.push("## Description", "", pull.body.trim(), "");
}
if (detail && detail.reviews.length) {
lines.push("## Reviews", "");
for (const review of detail.reviews) {
const author = review.user ? review.user.login : "someone";
lines.push(
`### ${nodes.reviewGlyph(review.state)} ${author}${String(
review.state || "",
).toLowerCase()} · ${time.relative(review.submitted_at || review.updated_at)}`,
);
if (review.body && review.body.trim()) lines.push("", review.body.trim());
const own = detail.comments.filter(
(comment) => comment.pull_request_review_id === review.id,
);
for (const comment of own) {
lines.push(
"",
`- \`${comment.path}${comment.position ? `:${comment.position}` : ""}\`${
(comment.body || "").trim() || "(empty)"
}`,
);
}
lines.push("");
}
}
if (timeline && timeline.length) {
lines.push("## Timeline", "");
for (const entry of timeline) {
const author = entry.user ? entry.user.login : "someone";
const when = time.relative(entry.created_at);
const kind = entry.type || "comment";
const body = (entry.body || "").trim();
lines.push(`- **${author}** ${kind} · ${when}${body ? `\n > ${body.replace(/\n/g, "\n > ")}` : ""}`);
}
lines.push("");
}
if (detail && detail.files.length) {
lines.push("## Changed Files", "");
for (const file of detail.files) {
lines.push(
`- \`${file.filename}\`${file.status}, +${file.additions || 0} ${
file.deletions || 0
}`,
);
}
lines.push("");
}
return lines.join("\n");
}
function register(store, refresh) {
nova.commands.register("gitea.openPullRequestOverview", async (argument) => {
const { repo, pull } = await contextFrom(argument, store);
if (!repo || !pull) return;
const detail =
store.detailFor(repo.key, pull.number) ||
(await store.loadPullRequestDetail(repo, pull));
const timeline = await store
.apiFor(repo)
.timeline(repo, pull.number)
.catch(() => []);
await ui.openText(renderOverview(repo, pull, detail, timeline), { syntax: "markdown" });
});
nova.commands.register("gitea.openPullRequestDiff", async (argument) => {
const { repo, pull } = await contextFrom(argument, store);
if (!repo || !pull) return;
let diff;
try {
diff = await store.apiFor(repo).pullRequestDiff(repo, pull.number);
} catch (error) {
ui.error(`Could not fetch the diff: ${error.message || error}`);
return;
}
if (!diff || !diff.trim()) {
ui.info(`Pull request #${pull.number} has no textual diff.`);
return;
}
const path = repo.dir
? nova.path.join(repo.dir, ".tmp", "gitea-diffs", `pr-${pull.number}.diff`)
: null;
await ui.openText(diff, { path: path, syntax: "diff" });
});
nova.commands.register("gitea.openChangedFile", async (argument) => {
const node = selection.selectedNode(argument);
const file = node && node.data ? node.data.file : null;
const repo = selection.repoOf(node);
const pull = selection.pullRequestFor(node);
if (!file || !repo || !pull) {
ui.warn("Select a changed file in the Gitea sidebar first.");
return;
}
// Prefer the working copy when the branch is checked out locally.
if (repo.dir) {
const local = nova.path.join(repo.dir, file.filename);
if (nova.fs.access(local, nova.fs.F_OK)) {
await nova.workspace.openFile(local);
return;
}
}
try {
const diff = await store.apiFor(repo).pullRequestDiff(repo, pull.number);
const section = extractFileDiff(diff, file.filename);
await ui.openText(section || diff, { syntax: "diff" });
} catch (error) {
ui.error(`Could not open ${file.filename}: ${error.message || error}`);
}
});
nova.commands.register("gitea.checkoutPrBranch", async (argument) => {
const { repo, pull } = await contextFrom(argument, store);
if (!repo || !pull) return;
if (!repo.dir) {
ui.warn(
`${repo.fullName} is not checked out in this workspace, so its branch cannot be switched.`,
);
return;
}
if (!pull.head || !pull.head.ref) {
ui.warn("This pull request does not report a head branch.");
return;
}
const remote = repo.remoteName || "origin";
const failure = await git.checkoutBranch(repo.dir, pull.head.ref, remote);
if (failure) {
ui.error(`Could not check out ${pull.head.ref}: ${failure}`);
return;
}
ui.info(`Checked out ${pull.head.ref}.`);
await refresh({ rediscover: true });
});
nova.commands.register("gitea.createPullRequest", async (argument) => {
const repo = await selection.resolveRepo(argument, {
prompt: "Create a pull request in which repository?",
});
if (!repo) return;
const api = store.apiFor(repo);
let branches = [];
try {
branches = (await api.branches(repo)).map((branch) => branch.name);
} catch (error) {
ui.error(`Could not list branches: ${error.message || error}`);
return;
}
if (!branches.length) {
ui.warn(`${repo.fullName} has no branches to open a pull request from.`);
return;
}
const current = repo.dir ? await git.currentBranch(repo.dir) : null;
const headChoices = current
? [current, ...branches.filter((name) => name !== current)]
: branches;
const head = await ui.choose(headChoices, { placeholder: "Source branch (head)" });
if (!head) return;
const baseChoices = branches.filter((name) => name !== head);
const base = await ui.choose(baseChoices, { placeholder: "Target branch (base)" });
if (!base) return;
const title = await ui.input("Pull request title", {
placeholder: `Merge ${head} into ${base}`,
value: head.replace(/[-_/]+/g, " "),
});
if (!title) return;
const body = await ui.input("Description (optional)", { placeholder: "Markdown" });
try {
const created = await api.createPullRequest(repo, {
head: head,
base: base,
title: title,
body: body || "",
});
ui.info(`Created #${created.number} in ${repo.fullName}.`);
if (created.html_url && (await ui.confirm("Open it in the browser?", "Open"))) {
nova.openURL(created.html_url);
}
} catch (error) {
ui.error(`Could not create the pull request: ${error.message || error}`);
return;
}
await refresh();
});
nova.commands.register("gitea.mergePullRequest", async (argument) => {
const { repo, pull } = await contextFrom(argument, store);
if (!repo || !pull) return;
if (pull.mergeable === false) {
ui.warn(`#${pull.number} has conflicts and cannot be merged.`);
return;
}
const strategy = await ui.choose(MERGE_STRATEGIES, {
placeholder: `Merge #${pull.number} how?`,
label: (entry) => entry.label,
});
if (!strategy) return;
if (!(await ui.confirm(`Merge #${pull.number} (${strategy.label})?`, "Merge"))) return;
try {
await store.apiFor(repo).mergePullRequest(repo, pull.number, {
strategy: strategy.value,
title: pull.title,
});
ui.info(`Merged #${pull.number}.`);
} catch (error) {
ui.error(`Could not merge #${pull.number}: ${error.message || error}`);
return;
}
await refresh();
});
nova.commands.register("gitea.closePullRequest", async (argument) => {
const { repo, pull } = await contextFrom(argument, store);
if (!repo || !pull) return;
if (!(await ui.confirm(`Close #${pull.number} without merging?`, "Close"))) return;
try {
await store.apiFor(repo).closePullRequest(repo, pull.number);
ui.info(`Closed #${pull.number}.`);
} catch (error) {
ui.error(`Could not close #${pull.number}: ${error.message || error}`);
return;
}
await refresh();
});
}
/** Slices the hunks for one file out of a unified diff. */
function extractFileDiff(diff, filename) {
if (!diff) return null;
const lines = diff.split("\n");
const start = lines.findIndex(
(line) => line.startsWith("diff --git ") && line.includes(` b/${filename}`),
);
if (start === -1) return null;
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
if (lines[index].startsWith("diff --git ")) {
end = index;
break;
}
}
return lines.slice(start, end).join("\n");
}
exports.register = register;
exports.extractFileDiff = extractFileDiff;
exports.renderOverview = renderOverview;
+314
View File
@@ -0,0 +1,314 @@
//
// Reviewing pull requests: submitting approvals and change requests, adding a
// comment anchored to the line under the cursor, replying, and resolving.
//
const config = require("../util/config.js");
const selection = require("./selection.js");
const ui = require("../ui.js");
/**
* Converts a review comment's diff hunk into a line number in the new file.
* The commented line is the last line of the hunk, so counting forward from the
* hunk header gives its position.
*/
function lineFromDiffHunk(diffHunk, fallback) {
if (!diffHunk) return fallback || null;
const lines = diffHunk.split("\n");
let headerIndex = -1;
let start = null;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const match = lines[index].match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match) {
headerIndex = index;
start = Number(match[1]);
break;
}
}
if (headerIndex === -1 || start === null) return fallback || null;
let line = start - 1;
for (let index = headerIndex + 1; index < lines.length; index += 1) {
// Removed lines do not exist in the new file, so they do not advance it.
if (!lines[index].startsWith("-")) line += 1;
}
return Math.max(1, line);
}
/** 1-based line number of the start of the editor's current selection. */
function selectedLine(editor) {
const range = editor.selectedRange;
const before = editor.getTextInRange(new Range(0, range.start));
return before.split("\n").length;
}
/** The repository whose checkout contains `path`, if any. */
function repoContaining(store, path) {
if (!path) return null;
let best = null;
for (const repo of store.repos) {
if (!repo.dir) continue;
if (path === repo.dir || path.startsWith(repo.dir + "/")) {
if (!best || repo.dir.length > best.dir.length) best = repo;
}
}
return best;
}
function relativePath(repo, path) {
return path.slice(repo.dir.length).replace(/^\/+/, "");
}
/** Open pull requests whose head branch is currently checked out in `repo`. */
function pullRequestsForCheckout(store, repo) {
const branch = repo.branch;
const items = store.pullRequestsFor(repo.key).items;
if (!branch) return items;
const matching = items.filter((pull) => pull.head && pull.head.ref === branch);
return matching.length ? matching : items;
}
async function submit(store, refresh, repo, pull, event, prompt) {
let body = "";
if (prompt) {
const entered = await ui.input(prompt, { placeholder: "Markdown" });
if (entered === null) return;
body = entered;
}
if (event === "REQUEST_CHANGES" && !body.trim()) {
ui.warn("Gitea requires a message when requesting changes.");
return;
}
try {
await store.apiFor(repo).submitReview(repo, pull.number, { event: event, body: body });
} catch (error) {
const hint = error.status === 403 ? " The token needs pull request write access." : "";
ui.error(`Could not submit the review: ${error.message || error}${hint}`);
return;
}
ui.info(`Review submitted on #${pull.number}.`);
store.pullRequestDetail.delete(`${repo.key}:${pull.number}`);
await refresh();
}
async function pickPullRequest(store, argument) {
const node = selection.selectedNode(argument);
const pull = selection.pullRequestFor(node);
const repo = selection.repoOf(node);
if (pull && repo) return { repo, pull };
const chosen = repo || (await selection.resolveRepo(argument));
if (!chosen) return { repo: null, pull: null };
const items = store.pullRequestsFor(chosen.key).items;
if (!items.length) {
ui.info(`No open pull requests in ${chosen.fullName}.`);
return { repo: chosen, pull: null };
}
const picked =
items.length === 1
? items[0]
: await ui.choose(items, {
placeholder: "Review which pull request?",
label: (item) => `#${item.number} ${item.title}`,
});
return { repo: chosen, pull: picked };
}
function register(store, refresh, reviewComments) {
nova.commands.register("gitea.reviewApprove", async (argument) => {
const { repo, pull } = await pickPullRequest(store, argument);
if (!repo || !pull) return;
await submit(store, refresh, repo, pull, "APPROVED", `Approve #${pull.number} (optional message)`);
});
nova.commands.register("gitea.reviewRequestChanges", async (argument) => {
const { repo, pull } = await pickPullRequest(store, argument);
if (!repo || !pull) return;
await submit(
store,
refresh,
repo,
pull,
"REQUEST_CHANGES",
`What should change in #${pull.number}?`,
);
});
nova.commands.register("gitea.reviewComment", async (argument) => {
const { repo, pull } = await pickPullRequest(store, argument);
if (!repo || !pull) return;
await submit(store, refresh, repo, pull, "COMMENT", `Comment on #${pull.number}`);
});
// Invoked from the Editor menu: comments on the line under the cursor.
nova.commands.register("gitea.addReviewComment", async (editor) => {
const target = editor && editor.document ? editor : nova.workspace.activeTextEditor;
if (!target || !target.document || !target.document.path) {
ui.warn("Open a saved file in the repository first.");
return;
}
const repo = repoContaining(store, target.document.path);
if (!repo) {
ui.warn("This file is not inside a discovered Gitea repository.");
return;
}
const candidates = pullRequestsForCheckout(store, repo);
if (!candidates.length) {
ui.info(`No open pull requests in ${repo.fullName}.`);
return;
}
const pull =
candidates.length === 1
? candidates[0]
: await ui.choose(candidates, {
placeholder: "Comment on which pull request?",
label: (item) => `#${item.number} ${item.title}`,
});
if (!pull) return;
const path = relativePath(repo, target.document.path);
const line = selectedLine(target);
const body = await ui.input(`Comment on ${path}:${line}`, { placeholder: "Markdown" });
if (!body) return;
try {
await store.apiFor(repo).submitReview(repo, pull.number, {
event: "COMMENT",
body: "",
comments: [{ path: path, body: body, new_position: line }],
});
} catch (error) {
const hint = error.status === 403 ? " The token needs pull request write access." : "";
ui.error(`Could not add the comment: ${error.message || error}${hint}`);
return;
}
ui.info(`Commented on ${path}:${line} in #${pull.number}.`);
store.pullRequestDetail.delete(`${repo.key}:${pull.number}`);
await refresh();
if (reviewComments) await reviewComments.reload();
});
nova.commands.register("gitea.replyToReviewComment", async (argument) => {
const node = selection.selectedNode(argument);
const comment = node && node.data ? node.data.comment : null;
const repo = selection.repoOf(node);
const pull = selection.pullRequestFor(node);
if (!comment || !repo || !pull) {
ui.warn("Select a review comment first.");
return;
}
const body = await ui.input("Reply", { placeholder: "Markdown" });
if (!body) return;
try {
await store.apiFor(repo).replyToReviewComment(repo, pull.number, comment.id, body);
} catch (error) {
ui.error(`Could not post the reply: ${error.message || error}`);
return;
}
ui.info("Reply posted.");
store.pullRequestDetail.delete(`${repo.key}:${pull.number}`);
await refresh();
if (reviewComments) await reviewComments.reload();
});
const setResolved = async (argument, resolved) => {
const node = selection.selectedNode(argument);
const comment = node && node.data ? node.data.comment : null;
const repo = selection.repoOf(node);
const pull = selection.pullRequestFor(node);
if (!comment || !repo) {
ui.warn("Select a review comment first.");
return;
}
const api = store.apiFor(repo);
try {
if (resolved) await api.resolveReviewThread(repo, comment.id);
else await api.unresolveReviewThread(repo, comment.id);
} catch (error) {
ui.error(
`Could not ${resolved ? "resolve" : "unresolve"} the thread: ${
error.message || error
}`,
);
return;
}
ui.info(resolved ? "Thread resolved." : "Thread reopened.");
if (pull) store.pullRequestDetail.delete(`${repo.key}:${pull.number}`);
await refresh();
if (reviewComments) await reviewComments.reload();
};
nova.commands.register("gitea.resolveReviewThread", (argument) => setResolved(argument, true));
nova.commands.register("gitea.unresolveReviewThread", (argument) => setResolved(argument, false));
// Jumps from a comment row to the commented line in the working copy.
nova.commands.register("gitea.openReviewComment", async (argument) => {
const node = selection.selectedNode(argument);
const comment = node && node.data ? node.data.comment : null;
const repo = selection.repoOf(node);
if (!comment || !repo) return;
if (!repo.dir || !comment.path) {
if (comment.html_url) nova.openURL(comment.html_url);
return;
}
const path = nova.path.join(repo.dir, comment.path);
if (!nova.fs.access(path, nova.fs.F_OK)) {
if (comment.html_url) nova.openURL(comment.html_url);
return;
}
const line = lineFromDiffHunk(comment.diff_hunk, comment.position);
await nova.workspace.openFile(path, line ? { line: line } : undefined);
});
nova.commands.register("gitea.switchBranchFilter", async () => {
const options = [
{ mode: "current", label: "Checked-out branch" },
{ mode: "all", label: "All branches" },
{ mode: "custom", label: "A specific branch…" },
];
const picked = await ui.choose(options, {
placeholder: "Which branch should the Current Branch section track?",
label: (option) => option.label,
});
if (!picked) return;
if (picked.mode === "custom") {
const current = config.branchFilter();
const branch = await ui.input("Track which branch?", {
placeholder: "e.g. main",
value: current.branch || "",
});
if (!branch) return;
config.setBranchFilter("custom", branch.trim());
} else {
config.setBranchFilter(picked.mode, null);
}
await refresh();
if (reviewComments) await reviewComments.reload();
});
}
exports.register = register;
exports.lineFromDiffHunk = lineFromDiffHunk;
exports.repoContaining = repoContaining;
+155
View File
@@ -0,0 +1,155 @@
//
// Inline review comments. Nova has no editor decoration or comment-thread API,
// so pull request review comments for the current branch are published as
// issues: they appear in the gutter, on hover, and in the Issues sidebar.
//
const config = require("../util/config.js");
const log = require("../util/log.js");
const { lineFromDiffHunk } = require("./review.js");
const DEBOUNCE_MS = 750;
function uriFor(path) {
return `file://${encodeURI(path).replace(/#/g, "%23")}`;
}
function severityFor(comment) {
if (comment.resolver && comment.resolver.login) return IssueSeverity.Info;
const state = String(comment.review ? comment.review.state : "").toUpperCase();
return state === "REQUEST_CHANGES" ? IssueSeverity.Error : IssueSeverity.Warning;
}
class ReviewCommentsController {
constructor(store) {
this.store = store;
this.collection = new IssueCollection("Gitea Review");
this._paths = new Set();
this._signature = null;
this._timer = null;
this._running = false;
this._subscription = store.onDidChange(() => this.scheduleReload());
}
/** Coalesces the frequent store updates into one rebuild. */
scheduleReload() {
if (this._timer) clearTimeout(this._timer);
this._timer = setTimeout(() => {
this._timer = null;
this.reload();
}, DEBOUNCE_MS);
}
/**
* Cheap fingerprint of the pull requests in scope. When it is unchanged the
* comment fetch is skipped, which keeps idle polling free of API calls.
*/
signature(targets) {
return targets
.map(({ repo, pull }) => `${repo.key}:${pull.number}:${pull.updated_at}`)
.sort()
.join("|");
}
/** Pull requests whose head branch matches the branch filter, per repo. */
targets() {
const found = [];
for (const repo of this.store.repos) {
if (!repo.dir) continue; // Without a checkout there is no file to annotate.
const branch = this.store.branchFor(repo);
for (const pull of this.store.pullRequestsFor(repo.key).items) {
if (branch && (!pull.head || pull.head.ref !== branch)) continue;
found.push({ repo, pull });
}
}
return found;
}
async reload({ force = false } = {}) {
if (this._running) return;
if (!config.reviewCommentsEnabled()) {
this.clear();
this._signature = null;
return;
}
const targets = this.targets();
const signature = this.signature(targets);
if (!force && signature === this._signature) return;
this._running = true;
try {
const byPath = new Map();
for (const { repo, pull } of targets) {
const detail =
this.store.detailFor(repo.key, pull.number) ||
(await this.store.loadPullRequestDetail(repo, pull));
if (!detail) continue;
for (const comment of detail.comments) {
if (!comment.path) continue;
const path = nova.path.join(repo.dir, comment.path);
if (!nova.fs.access(path, nova.fs.F_OK)) continue;
const issue = new Issue();
issue.source = `Gitea #${pull.number}`;
issue.code = String(comment.id);
issue.severity = severityFor(comment);
issue.message = this.describe(comment);
const line = lineFromDiffHunk(comment.diff_hunk, comment.position) || 1;
issue.line = line;
issue.column = 1;
issue.endLine = line;
issue.endColumn = 1;
if (!byPath.has(path)) byPath.set(path, []);
byPath.get(path).push(issue);
}
}
this.apply(byPath);
this._signature = signature;
} catch (error) {
log.error("review comments failed", String(error && error.stack ? error.stack : error));
} finally {
this._running = false;
}
}
describe(comment) {
const author = (comment.user && comment.user.login) || "someone";
const resolved = comment.resolver && comment.resolver.login ? " (resolved)" : "";
const body = String(comment.body || "").trim() || "(empty comment)";
return `${author}${resolved}: ${body}`;
}
/** Replaces the published set, clearing files that no longer have comments. */
apply(byPath) {
for (const path of this._paths) {
if (!byPath.has(path)) this.collection.remove(uriFor(path));
}
for (const [path, issues] of byPath) {
this.collection.set(uriFor(path), issues);
}
this._paths = new Set(byPath.keys());
}
clear() {
this.collection.clear();
this._paths = new Set();
}
dispose() {
if (this._timer) clearTimeout(this._timer);
if (this._subscription) this._subscription.dispose();
this.collection.dispose();
}
}
exports.ReviewCommentsController = ReviewCommentsController;
exports.uriFor = uriFor;
+78
View File
@@ -0,0 +1,78 @@
//
// Re-running and cancelling workflow runs.
//
const nodes = require("../views/nodes.js");
const selection = require("./selection.js");
const ui = require("../ui.js");
function contextFrom(argument) {
const node = selection.selectedNode(argument);
const repo = selection.repoOf(node);
const run = selection.runFor(node);
const job = node && node.data ? node.data.job : null;
return { node, repo, run, job };
}
/** Runs `action`, reports the outcome, and refreshes so the new state shows. */
async function perform(store, refresh, repo, label, action) {
try {
await action();
ui.info(`${label} requested for ${repo.fullName}.`);
} catch (error) {
const hint = error.status === 403 ? " The token needs Actions write access." : "";
ui.error(`${label} failed: ${error.message || error}${hint}`);
return;
}
await refresh();
}
function register(store, refresh) {
nova.commands.register("gitea.rerunRun", async (argument) => {
const { repo, run } = contextFrom(argument);
if (!repo || !run) {
ui.warn("Select a workflow run first.");
return;
}
await perform(store, refresh, repo, `Re-run of ${nodes.runTitle(run)}`, () =>
store.apiFor(repo).rerunRun(repo, run.id),
);
});
nova.commands.register("gitea.rerunFailedJobs", async (argument) => {
const { repo, run } = contextFrom(argument);
if (!repo || !run) {
ui.warn("Select a workflow run first.");
return;
}
await perform(store, refresh, repo, `Re-run of failed jobs in ${nodes.runTitle(run)}`, () =>
store.apiFor(repo).rerunFailedJobs(repo, run.id),
);
});
nova.commands.register("gitea.rerunJob", async (argument) => {
const { repo, run, job } = contextFrom(argument);
if (!repo || !run || !job) {
ui.warn("Select a job first.");
return;
}
await perform(store, refresh, repo, `Re-run of ${job.name || job.id}`, () =>
store.apiFor(repo).rerunJob(repo, run.id, job.id),
);
});
nova.commands.register("gitea.cancelRun", async (argument) => {
const { repo, run } = contextFrom(argument);
if (!repo || !run) {
ui.warn("Select a workflow run first.");
return;
}
if (!(await ui.confirm(`Cancel ${nodes.runTitle(run)}?`, "Cancel Run"))) return;
await perform(store, refresh, repo, `Cancellation of ${nodes.runTitle(run)}`, () =>
store.apiFor(repo).cancelRun(repo, run.id),
);
});
}
exports.register = register;
+194
View File
@@ -0,0 +1,194 @@
//
// Repository-level Actions secrets and variables.
//
const selection = require("./selection.js");
const ui = require("../ui.js");
const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
function validateName(name) {
if (!name) return "A name is required.";
if (!NAME_PATTERN.test(name)) {
return "Names may contain letters, digits, and underscores, and cannot start with a digit.";
}
return null;
}
async function repoFor(argument, store, prompt) {
return await selection.resolveRepo(argument, { prompt: prompt });
}
function register(store) {
const reloadSecrets = (repo) => store.loadSecrets(repo);
const reloadVariables = (repo) => store.loadVariables(repo);
nova.commands.register("gitea.refreshSecrets", async (argument) => {
const repo = await repoFor(argument, store, "Refresh secrets for which repository?");
if (repo) await reloadSecrets(repo);
});
nova.commands.register("gitea.refreshVariables", async (argument) => {
const repo = await repoFor(argument, store, "Refresh variables for which repository?");
if (repo) await reloadVariables(repo);
});
nova.commands.register("gitea.createSecret", async (argument) => {
const repo = await repoFor(argument, store, "Add a secret to which repository?");
if (!repo) return;
const name = await ui.input(`New secret in ${repo.fullName}`, {
placeholder: "SECRET_NAME",
});
if (!name) return;
const problem = validateName(name.trim());
if (problem) {
ui.warn(problem);
return;
}
const value = await ui.input(`Value for ${name.trim()}`, {
placeholder: "Secret value",
secure: true,
});
if (value === null) return;
try {
await store.apiFor(repo).setSecret(repo, name.trim(), value);
ui.info(`Saved secret ${name.trim()}.`);
} catch (error) {
ui.error(`Could not save the secret: ${error.message || error}`);
return;
}
await reloadSecrets(repo);
});
nova.commands.register("gitea.updateSecret", async (argument) => {
const node = selection.selectedNode(argument);
const secret = node && node.data ? node.data.secret : null;
const repo = selection.repoOf(node);
if (!secret || !repo) {
ui.warn("Select a secret first.");
return;
}
// Gitea never returns a secret's value, so an update always sets a new one.
const value = await ui.input(`New value for ${secret.name}`, {
placeholder: "Secret value",
secure: true,
});
if (value === null) return;
try {
await store.apiFor(repo).setSecret(repo, secret.name, value);
ui.info(`Updated secret ${secret.name}.`);
} catch (error) {
ui.error(`Could not update the secret: ${error.message || error}`);
return;
}
await reloadSecrets(repo);
});
nova.commands.register("gitea.deleteSecret", async (argument) => {
const node = selection.selectedNode(argument);
const secret = node && node.data ? node.data.secret : null;
const repo = selection.repoOf(node);
if (!secret || !repo) {
ui.warn("Select a secret first.");
return;
}
if (!(await ui.confirm(`Delete secret ${secret.name} from ${repo.fullName}?`, "Delete"))) {
return;
}
try {
await store.apiFor(repo).deleteSecret(repo, secret.name);
ui.info(`Deleted secret ${secret.name}.`);
} catch (error) {
ui.error(`Could not delete the secret: ${error.message || error}`);
return;
}
await reloadSecrets(repo);
});
nova.commands.register("gitea.createVariable", async (argument) => {
const repo = await repoFor(argument, store, "Add a variable to which repository?");
if (!repo) return;
const name = await ui.input(`New variable in ${repo.fullName}`, {
placeholder: "VARIABLE_NAME",
});
if (!name) return;
const problem = validateName(name.trim());
if (problem) {
ui.warn(problem);
return;
}
const value = await ui.input(`Value for ${name.trim()}`, { placeholder: "Value" });
if (value === null) return;
try {
await store.apiFor(repo).createVariable(repo, name.trim(), value);
ui.info(`Created variable ${name.trim()}.`);
} catch (error) {
ui.error(`Could not create the variable: ${error.message || error}`);
return;
}
await reloadVariables(repo);
});
nova.commands.register("gitea.updateVariable", async (argument) => {
const node = selection.selectedNode(argument);
const variable = node && node.data ? node.data.variable : null;
const repo = selection.repoOf(node);
if (!variable || !repo) {
ui.warn("Select a variable first.");
return;
}
const value = await ui.input(`New value for ${variable.name}`, {
placeholder: "Value",
value: variable.data || variable.value || "",
});
if (value === null) return;
try {
await store.apiFor(repo).updateVariable(repo, variable.name, value);
ui.info(`Updated variable ${variable.name}.`);
} catch (error) {
ui.error(`Could not update the variable: ${error.message || error}`);
return;
}
await reloadVariables(repo);
});
nova.commands.register("gitea.deleteVariable", async (argument) => {
const node = selection.selectedNode(argument);
const variable = node && node.data ? node.data.variable : null;
const repo = selection.repoOf(node);
if (!variable || !repo) {
ui.warn("Select a variable first.");
return;
}
if (
!(await ui.confirm(`Delete variable ${variable.name} from ${repo.fullName}?`, "Delete"))
) {
return;
}
try {
await store.apiFor(repo).deleteVariable(repo, variable.name);
ui.info(`Deleted variable ${variable.name}.`);
} catch (error) {
ui.error(`Could not delete the variable: ${error.message || error}`);
return;
}
await reloadVariables(repo);
});
}
exports.register = register;
exports.validateName = validateName;
+96
View File
@@ -0,0 +1,96 @@
//
// Commands are invoked from three places — a sidebar section, the Extensions
// menu, and notifications — and each passes something different. These helpers
// normalise that into "the node the user meant" and "the repository it is in".
//
const ui = require("../ui.js");
let registry = { store: null, treeViews: [] };
function configure(store, treeViews) {
registry = { store: store, treeViews: treeViews };
}
/** Unwraps a TreeView, a node, or nothing at all into a node. */
function selectedNode(argument) {
if (argument && Array.isArray(argument.selection)) {
return argument.selection[0] || null;
}
if (argument && argument.kind) return argument;
// Menu and palette invocations carry no argument; fall back to whichever
// visible section currently has a selection.
for (const view of registry.treeViews) {
if (view.visible && view.selection && view.selection.length) return view.selection[0];
}
return null;
}
function repoOf(node) {
if (!node) return null;
if (node.repo) return node.repo;
if (node.data && node.data.repoKey) return registry.store.repoFor(node.data.repoKey);
return null;
}
/**
* Resolves the repository to act on: the selected node's, the only discovered
* one, or whichever the user picks.
*/
async function resolveRepo(argument, { prompt = "Choose a repository" } = {}) {
const node = selectedNode(argument);
const fromNode = repoOf(node);
if (fromNode) return fromNode;
const repos = registry.store.repos;
if (!repos.length) {
ui.warn("No Gitea repositories are available yet.");
return null;
}
if (repos.length === 1) return repos[0];
return await ui.choose(repos, {
placeholder: prompt,
label: (repo) => `${repo.fullName}${repo.baseUrl.replace(/^https?:\/\//, "")}`,
});
}
/** Walks up from `node` to the nearest ancestor of the given kind. */
function ancestorOfKind(node, kind) {
let current = node;
while (current) {
if (current.kind === kind) return current;
current = current.parent;
}
return null;
}
/** Finds the pull request a node belongs to, looking at the node and its parents. */
function pullRequestFor(node) {
let current = node;
while (current) {
if (current.data && current.data.pull) return current.data.pull;
if (current.kind === "pr" && current.data && current.data.pull) return current.data.pull;
current = current.parent;
}
return null;
}
function runFor(node) {
let current = node;
while (current) {
if (current.data && current.data.run) return current.data.run;
current = current.parent;
}
return null;
}
exports.configure = configure;
exports.selectedNode = selectedNode;
exports.repoOf = repoOf;
exports.resolveRepo = resolveRepo;
exports.ancestorOfKind = ancestorOfKind;
exports.pullRequestFor = pullRequestFor;
exports.runFor = runFor;
exports.store = () => registry.store;
+338
View File
@@ -0,0 +1,338 @@
//
// Endpoint methods for the Gitea v1 API. Each takes a `repo` of the shape
// { owner, name } and returns plain objects with the fields the views need.
//
const { GiteaError } = require("./client.js");
const log = require("../util/log.js");
function segment(value) {
return encodeURIComponent(String(value));
}
function repoPath(repo, suffix = "") {
return `/repos/${segment(repo.owner)}/${segment(repo.name)}${suffix}`;
}
class GiteaApi {
constructor(client) {
this.client = client;
}
get baseUrl() {
return this.client.baseUrl;
}
// -- Instance ---------------------------------------------------------
async version() {
const body = await this.client.get("/version");
return (body && body.version) || "unknown";
}
async currentUser() {
return await this.client.get("/user");
}
async accessibleRepositories(limit = 100) {
return await this.client.paginate("/user/repos", { limit: limit });
}
async repository(repo) {
return await this.client.get(repoPath(repo));
}
async branches(repo, limit = 100) {
return await this.client.paginate(repoPath(repo, "/branches"), { limit: limit });
}
async combinedStatus(repo, ref) {
return await this.client.get(repoPath(repo, `/commits/${segment(ref)}/status`));
}
// -- Actions: runs ----------------------------------------------------
/**
* Lists workflow runs. Gitea gained /actions/runs in 1.24; older servers only
* expose /actions/tasks, which the caller sees as the "recent runs" fallback.
*/
async listRuns(repo, { limit = 20, branch = null, event = null } = {}) {
const query = { limit: limit, branch: branch, event: event };
try {
const body = await this.client.get(repoPath(repo, "/actions/runs"), { query: query });
return {
runs: (body && body.workflow_runs) || [],
total: (body && body.total_count) || 0,
legacy: false,
};
} catch (error) {
if (!(error instanceof GiteaError) || !error.isMissing) throw error;
log.debug("falling back to /actions/tasks for", repo.owner + "/" + repo.name);
const body = await this.client.get(repoPath(repo, "/actions/tasks"), {
query: { limit: limit },
});
return {
runs: (body && body.workflow_runs) || [],
total: (body && body.total_count) || 0,
legacy: true,
};
}
}
async listWorkflows(repo) {
try {
const body = await this.client.get(repoPath(repo, "/actions/workflows"));
return (body && body.workflows) || [];
} catch (error) {
if (error instanceof GiteaError && error.isMissing) return [];
throw error;
}
}
async listJobs(repo, runId, limit = 50) {
const body = await this.client.get(
repoPath(repo, `/actions/runs/${segment(runId)}/jobs`),
{ query: { limit: limit } },
);
return (body && body.jobs) || [];
}
async jobLogs(repo, jobId) {
return await this.client.get(repoPath(repo, `/actions/jobs/${segment(jobId)}/logs`), {
responseType: "text",
headers: { Accept: "text/plain, */*" },
});
}
async rerunRun(repo, runId) {
await this.client.post(repoPath(repo, `/actions/runs/${segment(runId)}/rerun`), null);
}
async rerunFailedJobs(repo, runId) {
await this.client.post(
repoPath(repo, `/actions/runs/${segment(runId)}/rerun-failed-jobs`),
null,
);
}
async rerunJob(repo, runId, jobId) {
await this.client.post(
repoPath(repo, `/actions/runs/${segment(runId)}/jobs/${segment(jobId)}/rerun`),
null,
);
}
async cancelRun(repo, runId) {
await this.client.post(repoPath(repo, `/actions/runs/${segment(runId)}/cancel`), null);
}
// -- Actions: artifacts -----------------------------------------------
async listArtifacts(repo, runId) {
try {
const body = await this.client.get(
repoPath(repo, `/actions/runs/${segment(runId)}/artifacts`),
);
return (body && body.artifacts) || [];
} catch (error) {
if (error instanceof GiteaError && error.isMissing) return [];
throw error;
}
}
/**
* Downloads an artifact zip. Gitea answers the zip endpoint with a redirect
* to storage; Nova's fetch follows it, so the bytes come back directly.
*/
async downloadArtifact(repo, artifact) {
const path = artifact.archive_download_url
? artifact.archive_download_url
: repoPath(repo, `/actions/artifacts/${segment(artifact.id)}/zip`);
return await this.client.get(path, {
responseType: "binary",
headers: { Accept: "application/zip, */*" },
});
}
// -- Actions: secrets and variables -----------------------------------
async listSecrets(repo) {
return await this.client.paginate(repoPath(repo, "/actions/secrets"), { limit: 100 });
}
async setSecret(repo, name, value) {
await this.client.put(repoPath(repo, `/actions/secrets/${segment(name)}`), {
data: value,
});
}
async deleteSecret(repo, name) {
await this.client.delete(repoPath(repo, `/actions/secrets/${segment(name)}`));
}
async listVariables(repo) {
return await this.client.paginate(repoPath(repo, "/actions/variables"), { limit: 100 });
}
async createVariable(repo, name, value) {
await this.client.post(repoPath(repo, `/actions/variables/${segment(name)}`), {
value: value,
});
}
async updateVariable(repo, name, value) {
await this.client.put(repoPath(repo, `/actions/variables/${segment(name)}`), {
value: value,
});
}
async deleteVariable(repo, name) {
await this.client.delete(repoPath(repo, `/actions/variables/${segment(name)}`));
}
// -- Pull requests -----------------------------------------------------
async listPullRequests(repo, { state = "open", limit = 30 } = {}) {
return await this.client.paginate(repoPath(repo, "/pulls"), {
limit: limit,
query: { state: state, sort: "recentupdate" },
});
}
async pullRequest(repo, index) {
return await this.client.get(repoPath(repo, `/pulls/${segment(index)}`));
}
async pullRequestFiles(repo, index, limit = 200) {
return await this.client.paginate(repoPath(repo, `/pulls/${segment(index)}/files`), {
limit: limit,
});
}
async pullRequestDiff(repo, index) {
return await this.client.get(repoPath(repo, `/pulls/${segment(index)}.diff`), {
responseType: "text",
headers: { Accept: "text/plain, */*" },
});
}
async pullRequestCommits(repo, index, limit = 100) {
return await this.client.paginate(repoPath(repo, `/pulls/${segment(index)}/commits`), {
limit: limit,
});
}
async createPullRequest(repo, { head, base, title, body }) {
return await this.client.post(repoPath(repo, "/pulls"), {
head: head,
base: base,
title: title,
body: body || "",
});
}
async closePullRequest(repo, index) {
return await this.client.patch(repoPath(repo, `/issues/${segment(index)}`), {
state: "closed",
});
}
async mergePullRequest(repo, index, { strategy = "merge", title, message } = {}) {
await this.client.post(repoPath(repo, `/pulls/${segment(index)}/merge`), {
Do: strategy,
MergeTitleField: title || "",
MergeMessageField: message || "",
});
}
// -- Reviews -----------------------------------------------------------
async listReviews(repo, index) {
return await this.client.paginate(repoPath(repo, `/pulls/${segment(index)}/reviews`), {
limit: 100,
});
}
async reviewComments(repo, index, reviewId) {
return await this.client.get(
repoPath(repo, `/pulls/${segment(index)}/reviews/${segment(reviewId)}/comments`),
);
}
/** Every review comment on a pull request, flattened across its reviews. */
async allReviewComments(repo, index) {
const reviews = await this.listReviews(repo, index);
const withComments = reviews.filter((review) => (review.comments_count || 0) > 0);
const batches = await Promise.all(
withComments.map(async (review) => {
try {
const comments = await this.reviewComments(repo, index, review.id);
return (comments || []).map((comment) =>
Object.assign({}, comment, { review: review }),
);
} catch (error) {
log.debug("review comments failed", review.id, String(error));
return [];
}
}),
);
return { reviews: reviews, comments: batches.flat() };
}
/**
* Submits a review. `event` is APPROVED, REQUEST_CHANGES, or COMMENT, and
* `comments` are file-anchored notes of the shape
* { path, body, new_position } / { path, body, old_position }.
*/
async submitReview(repo, index, { event, body = "", comments = [], commitId } = {}) {
return await this.client.post(repoPath(repo, `/pulls/${segment(index)}/reviews`), {
event: event,
body: body,
comments: comments,
commit_id: commitId,
});
}
async replyToReviewComment(repo, index, commentId, body) {
return await this.client.post(
repoPath(repo, `/pulls/${segment(index)}/comments/${segment(commentId)}/replies`),
{ body: body },
);
}
async resolveReviewThread(repo, commentId) {
await this.client.post(repoPath(repo, `/pulls/comments/${segment(commentId)}/resolve`), null);
}
async unresolveReviewThread(repo, commentId) {
await this.client.post(
repoPath(repo, `/pulls/comments/${segment(commentId)}/unresolve`),
null,
);
}
// -- Issue timeline ----------------------------------------------------
async timeline(repo, index, limit = 100) {
return await this.client.paginate(repoPath(repo, `/issues/${segment(index)}/timeline`), {
limit: limit,
});
}
async issueComments(repo, index, limit = 100) {
return await this.client.paginate(repoPath(repo, `/issues/${segment(index)}/comments`), {
limit: limit,
});
}
async addIssueComment(repo, index, body) {
return await this.client.post(repoPath(repo, `/issues/${segment(index)}/comments`), {
body: body,
});
}
}
exports.GiteaApi = GiteaApi;
exports.repoPath = repoPath;
+209
View File
@@ -0,0 +1,209 @@
//
// HTTP client for one Gitea instance. Handles auth, query building, pagination,
// binary downloads, and a small concurrency cap so a refresh across several
// repositories does not open dozens of sockets at once.
//
const log = require("../util/log.js");
const MAX_CONCURRENT = 6;
const PAGE_SIZE = 50;
class GiteaError extends Error {
constructor(message, status, body) {
super(message);
this.name = "GiteaError";
this.status = status;
this.body = body;
}
get isAuth() {
return this.status === 401 || this.status === 403;
}
get isMissing() {
return this.status === 404;
}
}
/** Runs at most `max` tasks at once, queueing the rest. */
class Limiter {
constructor(max) {
this.max = max;
this.active = 0;
this.queue = [];
}
run(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this._drain();
});
}
_drain() {
while (this.active < this.max && this.queue.length) {
const entry = this.queue.shift();
this.active += 1;
Promise.resolve()
.then(entry.task)
.then(entry.resolve, entry.reject)
.then(() => {
this.active -= 1;
this._drain();
});
}
}
}
function buildQuery(query) {
if (!query) return "";
const parts = [];
for (const key of Object.keys(query)) {
const value = query[key];
if (value === null || value === undefined || value === "") continue;
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
return parts.length ? `?${parts.join("&")}` : "";
}
class GiteaClient {
/**
* @param {String} baseUrl Instance root, without a trailing slash.
* @param {Function} tokenProvider Returns the token string, or null.
*/
constructor(baseUrl, tokenProvider) {
this.baseUrl = baseUrl;
this.tokenProvider = tokenProvider;
this.limiter = new Limiter(MAX_CONCURRENT);
}
get apiRoot() {
return `${this.baseUrl}/api/v1`;
}
get token() {
try {
return this.tokenProvider(this.baseUrl);
} catch (error) {
log.error("token lookup failed", String(error));
return null;
}
}
headers(extra) {
const headers = Object.assign({ Accept: "application/json" }, extra || {});
const token = this.token;
if (token) headers["Authorization"] = `token ${token}`;
return headers;
}
/** Absolute URLs (artifact redirects) pass through untouched. */
resolve(path) {
if (/^https?:\/\//i.test(path)) return path;
return `${this.apiRoot}${path.startsWith("/") ? path : "/" + path}`;
}
async request(method, path, options = {}) {
const url = this.resolve(path) + buildQuery(options.query);
const init = { method: method, headers: this.headers(options.headers) };
if (options.body !== undefined && options.body !== null) {
init.body = JSON.stringify(options.body);
init.headers["Content-Type"] = "application/json";
}
log.debug(method, url);
const response = await this.limiter.run(() => fetch(url, init));
if (!response.ok) {
let detail = "";
try {
detail = await response.text();
} catch (error) {
detail = "";
}
throw new GiteaError(
describeFailure(method, path, response.status, detail),
response.status,
detail,
);
}
if (options.responseType === "text") return await response.text();
if (options.responseType === "binary") return await response.arrayBuffer();
if (options.responseType === "response") return response;
if (response.status === 204) return null;
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch (error) {
throw new GiteaError(`Unexpected response from ${url}`, response.status, text);
}
}
get(path, options) {
return this.request("GET", path, options);
}
post(path, body, options) {
return this.request("POST", path, Object.assign({ body: body }, options));
}
put(path, body, options) {
return this.request("PUT", path, Object.assign({ body: body }, options));
}
patch(path, body, options) {
return this.request("PATCH", path, Object.assign({ body: body }, options));
}
delete(path, options) {
return this.request("DELETE", path, options);
}
/**
* Walks paged list endpoints until `limit` items are collected or the server
* returns a short page.
*/
async paginate(path, options = {}) {
const limit = options.limit || PAGE_SIZE;
const pageSize = Math.min(limit, PAGE_SIZE);
const collected = [];
for (let page = 1; collected.length < limit; page += 1) {
const query = Object.assign({}, options.query, { page: page, limit: pageSize });
const body = await this.get(path, { query: query });
const items = options.pick ? options.pick(body) : body;
if (!Array.isArray(items) || !items.length) break;
collected.push(...items);
if (items.length < pageSize) break;
}
return collected.slice(0, limit);
}
}
function describeFailure(method, path, status, detail) {
let message = "";
try {
const parsed = JSON.parse(detail);
message = parsed.message || parsed.error || "";
} catch (error) {
message = (detail || "").slice(0, 200);
}
if (status === 401) return "Gitea rejected the token (401). Set a valid token.";
if (status === 403) {
return `Gitea denied the request (403). The token is missing a required scope. ${message}`.trim();
}
if (status === 404) return `Not found: ${method} ${path}. ${message}`.trim();
return `Gitea request failed (${status}): ${method} ${path}. ${message}`.trim();
}
exports.GiteaClient = GiteaClient;
exports.GiteaError = GiteaError;
exports.Limiter = Limiter;
+56
View File
@@ -0,0 +1,56 @@
//
// Token storage. Tokens live in the macOS Keychain, one entry per instance,
// keyed by the instance's base URL. Reads are memoised because the sidebar
// consults them on every render and each miss is a synchronous Keychain call.
//
const SERVICE = "unsupervised.giteanova";
const log = require("../util/log.js");
const cache = new Map();
function getToken(baseUrl) {
if (!baseUrl) return null;
if (cache.has(baseUrl)) return cache.get(baseUrl);
let token = null;
try {
token = nova.credentials.getPassword(SERVICE, baseUrl);
} catch (error) {
log.error("keychain read failed", String(error));
}
cache.set(baseUrl, token);
return token;
}
function setToken(baseUrl, token) {
nova.credentials.setPassword(SERVICE, baseUrl, token);
cache.set(baseUrl, token);
}
function removeToken(baseUrl) {
try {
nova.credentials.removePassword(SERVICE, baseUrl);
} catch (error) {
log.debug("keychain delete failed", String(error));
}
cache.delete(baseUrl);
}
function hasToken(baseUrl) {
const token = getToken(baseUrl);
return Boolean(token && token.length);
}
/** Forgets memoised tokens, so the next read goes back to the Keychain. */
function invalidate(baseUrl) {
if (baseUrl) cache.delete(baseUrl);
else cache.clear();
}
exports.SERVICE = SERVICE;
exports.getToken = getToken;
exports.setToken = setToken;
exports.removeToken = removeToken;
exports.hasToken = hasToken;
exports.invalidate = invalidate;
+141
View File
@@ -0,0 +1,141 @@
//
// Repository discovery. In workspace mode the open folder's git remotes decide
// which repositories appear; in allAccessible mode every repository the token
// can reach is listed.
//
const config = require("../util/config.js");
const credentials = require("./credentials.js");
const git = require("../util/git.js");
const log = require("../util/log.js");
const remotes = require("./remotes.js");
/** Stable identity for a repository across refreshes. */
function keyFor(baseUrl, owner, name) {
return `${baseUrl}#${owner}/${name}`;
}
function makeRepo({ baseUrl, owner, name, dir = null, remoteName = null, remoteUrl = null }) {
return {
key: keyFor(baseUrl, owner, name),
baseUrl: baseUrl,
owner: owner,
name: name,
fullName: `${owner}/${name}`,
dir: dir,
remoteName: remoteName,
remoteUrl: remoteUrl,
htmlUrl: `${baseUrl}/${owner}/${name}`,
branch: null,
headSha: null,
};
}
async function discoverFromWorkspace(router) {
const root = nova.workspace.path;
if (!root) return [];
const dirs = await git.discoverRepositories(root);
const found = new Map();
for (const dir of dirs) {
const list = await git.remotes(dir);
if (!list.length) continue;
// Prefer origin, but consider every remote so forks still resolve.
const ordered = list.slice().sort((a, b) => {
if (a.name === b.name) return 0;
if (a.name === "origin") return -1;
if (b.name === "origin") return 1;
return 0;
});
for (const remote of ordered) {
const parsed = remotes.parse(remote.url);
if (!parsed) continue;
const baseUrl = router.baseUrlForHost(parsed.host);
if (!baseUrl) {
log.debug(`no configured instance for host ${parsed.host} (${remote.url})`);
continue;
}
const repo = makeRepo({
baseUrl: baseUrl,
owner: parsed.owner,
name: parsed.name,
dir: dir,
remoteName: remote.name,
remoteUrl: remote.url,
});
if (!found.has(repo.key)) {
repo.branch = await git.currentBranch(dir);
repo.headSha = await git.headSha(dir);
found.set(repo.key, repo);
}
break;
}
}
return [...found.values()];
}
async function discoverFromApi(router) {
const found = [];
for (const baseUrl of router.baseUrls) {
if (!credentials.hasToken(baseUrl)) continue;
const api = router.apiFor(baseUrl);
try {
const list = await api.accessibleRepositories(100);
for (const entry of list) {
const owner = (entry.owner && entry.owner.login) || entry.full_name.split("/")[0];
const name = entry.name;
found.push(
Object.assign(makeRepo({ baseUrl: baseUrl, owner: owner, name: name }), {
htmlUrl: entry.html_url || `${baseUrl}/${owner}/${name}`,
branch: entry.default_branch || null,
}),
);
}
} catch (error) {
log.error(`repository discovery failed for ${baseUrl}:`, String(error));
}
}
return found;
}
/**
* Discovers repositories for the configured mode. Workspace mode additionally
* merges in local checkouts so allAccessible never loses the local directory
* needed for checkout and log-saving.
*/
async function discover(router) {
const mode = config.discoveryMode();
const workspaceRepos = await discoverFromWorkspace(router);
if (mode !== "allAccessible") return workspaceRepos;
const byKey = new Map();
for (const repo of await discoverFromApi(router)) byKey.set(repo.key, repo);
for (const repo of workspaceRepos) {
const existing = byKey.get(repo.key);
if (existing) {
existing.dir = repo.dir;
existing.remoteName = repo.remoteName;
existing.remoteUrl = repo.remoteUrl;
existing.branch = repo.branch;
existing.headSha = repo.headSha;
} else {
byKey.set(repo.key, repo);
}
}
return [...byKey.values()].sort((a, b) => a.fullName.localeCompare(b.fullName));
}
exports.discover = discover;
exports.makeRepo = makeRepo;
exports.keyFor = keyFor;
+68
View File
@@ -0,0 +1,68 @@
//
// Git remote URL parsing. Covers the four shapes Gitea hands out: HTTPS, SCP
// style SSH, ssh:// URLs, and git:// URLs.
//
function stripCredentials(host) {
const at = host.lastIndexOf("@");
return at === -1 ? host : host.slice(at + 1);
}
function splitPath(path) {
const parts = path
.replace(/^\/+/, "")
.replace(/\.git$/i, "")
.replace(/\/+$/, "")
.split("/")
.filter(Boolean);
if (parts.length < 2) return null;
// Sub-path installs ("https://example.com/gitea/owner/repo") still end with
// owner/repo, so take the last two components.
return { owner: parts[parts.length - 2], name: parts[parts.length - 1] };
}
/** Parses a remote URL into { host, port, owner, name }, or null. */
function parse(url) {
if (!url) return null;
const trimmed = String(url).trim();
let match = trimmed.match(/^[a-z][a-z0-9+.-]*:\/\/([^/]+)(\/.*)$/i);
if (match) {
let authority = stripCredentials(match[1]);
let port = null;
const portMatch = authority.match(/^(.+):(\d+)$/);
if (portMatch) {
authority = portMatch[1];
port = Number(portMatch[2]);
}
const path = splitPath(match[2]);
if (!path) return null;
return { host: authority.toLowerCase(), port: port, owner: path.owner, name: path.name };
}
// SCP-like: git@host:owner/repo.git
match = trimmed.match(/^([^/@]+@)?([^/:]+):(.+)$/);
if (match) {
const path = splitPath(match[3]);
if (!path) return null;
return {
host: match[2].toLowerCase(),
port: null,
owner: path.owner,
name: path.name,
};
}
return null;
}
/** The host of an instance base URL, for matching against a remote's host. */
function hostOf(baseUrl) {
const match = String(baseUrl || "").match(/^[a-z][a-z0-9+.-]*:\/\/([^/]+)/i);
if (!match) return null;
return stripCredentials(match[1]).replace(/:\d+$/, "").toLowerCase();
}
exports.parse = parse;
exports.hostOf = hostOf;
+61
View File
@@ -0,0 +1,61 @@
//
// Instance routing. Repositories are matched to a configured Gitea instance by
// their git remote's host; anything unmatched falls back to the default one.
//
const config = require("../util/config.js");
const credentials = require("./credentials.js");
const remotes = require("./remotes.js");
const { GiteaClient } = require("./client.js");
const { GiteaApi } = require("./api.js");
class InstanceRouter {
constructor() {
this.apis = new Map();
}
/** Base URLs of every configured instance, default first. */
get baseUrls() {
return config.instances();
}
apiFor(baseUrl) {
const normalized = config.normalizeBaseUrl(baseUrl);
if (!normalized) return null;
let api = this.apis.get(normalized);
if (!api) {
api = new GiteaApi(new GiteaClient(normalized, credentials.getToken));
this.apis.set(normalized, api);
}
return api;
}
get defaultApi() {
const urls = this.baseUrls;
return urls.length ? this.apiFor(urls[0]) : null;
}
/** The instance whose host matches `host`, or null when none does. */
baseUrlForHost(host) {
if (!host) return null;
const target = host.toLowerCase();
for (const baseUrl of this.baseUrls) {
if (remotes.hostOf(baseUrl) === target) return baseUrl;
}
return null;
}
apiForHost(host) {
const baseUrl = this.baseUrlForHost(host);
return baseUrl ? this.apiFor(baseUrl) : null;
}
/** Drops cached clients and tokens so the next call re-reads preferences. */
invalidate() {
this.apis.clear();
credentials.invalidate();
}
}
exports.InstanceRouter = InstanceRouter;
+172
View File
@@ -0,0 +1,172 @@
//
// Entry point. Builds the store, the four sidebar sections, and the command
// handlers, then keeps the sections in step with the store.
//
const artifacts = require("./controllers/artifacts.js");
const auth = require("./controllers/auth.js");
const browser = require("./controllers/browser.js");
const log = require("./util/log.js");
const logs = require("./controllers/logs.js");
const pullRequests = require("./controllers/pullRequests.js");
const review = require("./controllers/review.js");
const runControl = require("./controllers/runControl.js");
const secretsVariables = require("./controllers/secretsVariables.js");
const selection = require("./controllers/selection.js");
const ui = require("./ui.js");
const { BranchProvider } = require("./views/branchView.js");
const { PullRequestsProvider } = require("./views/pullRequestsView.js");
const { ReviewCommentsController } = require("./controllers/reviewComments.js");
const { SettingsProvider } = require("./views/settingsView.js");
const { Store } = require("./store.js");
const { WorkflowsProvider } = require("./views/workflowsView.js");
const RELOAD_DEBOUNCE_MS = 150;
// Preferences that change which repositories exist, rather than only how they
// are displayed, force a full rediscovery.
const REDISCOVERY_KEYS = ["gitea.baseUrl", "gitea.instances", "gitea.discovery.mode"];
const DISPLAY_KEYS = [
"gitea.maxRunsPerRepo",
"gitea.maxJobsPerRun",
"gitea.branchFilter",
"gitea.branchFilter.custom",
];
let state = null;
function buildSections(store) {
const definitions = [
{ id: "gitea.branch", provider: new BranchProvider(store) },
{ id: "gitea.workflows", provider: new WorkflowsProvider(store) },
{ id: "gitea.pullRequests", provider: new PullRequestsProvider(store) },
{ id: "gitea.settings", provider: new SettingsProvider(store) },
];
return definitions.map((definition) => {
const view = new TreeView(definition.id, { dataProvider: definition.provider });
nova.subscriptions.add(view);
return Object.assign(definition, { view: view });
});
}
/** Rebuilds every section from current store state. */
function reloadSections(sections) {
for (const section of sections) {
section.provider.invalidate();
section.view.reload().catch((error) => log.debug("reload failed", String(error)));
}
}
function registerCommands(store, refresh, sections, reviewComments) {
auth.register(store, refresh);
logs.register(store);
artifacts.register(store);
runControl.register(store, refresh);
pullRequests.register(store, refresh);
review.register(store, refresh, reviewComments);
secretsVariables.register(store);
browser.register();
nova.commands.register("gitea.refresh", async () => {
await refresh({ rediscover: true });
await reviewComments.reload({ force: true });
});
// The per-section refresh buttons all drive the same fetch; they exist so
// each section header has an affordance of its own.
for (const name of ["gitea.refreshRuns", "gitea.refreshPullRequests", "gitea.refreshSettings"]) {
nova.commands.register(name, () => refresh());
}
nova.commands.register("gitea.refreshRepo", async (argument) => {
const repo = await selection.resolveRepo(argument, {
prompt: "Refresh which repository?",
});
if (repo) await store.refreshRepo(repo.key);
});
}
function watchPreferences(refresh) {
const subscriptions = [];
for (const key of REDISCOVERY_KEYS) {
subscriptions.push(nova.config.onDidChange(key, () => refresh({ rediscover: true })));
}
for (const key of DISPLAY_KEYS) {
subscriptions.push(nova.config.onDidChange(key, () => refresh()));
if (nova.workspace && nova.workspace.config) {
subscriptions.push(nova.workspace.config.onDidChange(key, () => refresh()));
}
}
if (nova.workspace && nova.workspace.config) {
subscriptions.push(
nova.workspace.config.onDidChange("gitea.baseUrl", () =>
refresh({ rediscover: true }),
),
);
}
for (const subscription of subscriptions) {
if (subscription) nova.subscriptions.add(subscription);
}
}
exports.activate = function () {
const store = new Store();
const sections = buildSections(store);
const reviewComments = new ReviewCommentsController(store);
selection.configure(
store,
sections.map((section) => section.view),
);
let timer = null;
const scheduleReload = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
reloadSections(sections);
}, RELOAD_DEBOUNCE_MS);
};
const subscription = store.onDidChange(scheduleReload);
const refresh = (options) =>
store.refresh(options).catch((error) => {
log.error("refresh failed", String(error));
});
registerCommands(store, refresh, sections, reviewComments);
watchPreferences(refresh);
state = {
store: store,
sections: sections,
reviewComments: reviewComments,
subscription: subscription,
cancelReload: () => {
if (timer) clearTimeout(timer);
},
};
log.info(`activated for ${store.router.baseUrls.join(", ") || "no configured instance"}`);
if (!store.router.baseUrls.length) {
ui.warn("Gitea: set an instance URL in the extension preferences to get started.");
return;
}
refresh({ rediscover: true }).then(() => reviewComments.reload({ force: true }));
};
exports.deactivate = function () {
if (!state) return;
state.cancelReload();
state.subscription.dispose();
state.reviewComments.dispose();
state.store.dispose();
state = null;
};
+413
View File
@@ -0,0 +1,413 @@
//
// Central state. Views read from here and re-render on change; controllers
// mutate through the refresh methods. Polling adapts to whether anything is
// currently running, mirroring the VS Code extension's active/idle intervals.
//
const config = require("./util/config.js");
const credentials = require("./gitea/credentials.js");
const discovery = require("./gitea/discovery.js");
const git = require("./util/git.js");
const log = require("./util/log.js");
const { InstanceRouter } = require("./gitea/router.js");
const ACTIVE_STATUSES = new Set(["queued", "waiting", "running", "in_progress", "pending"]);
const FAILURE_CONCLUSIONS = new Set(["failure", "failed", "error", "timed_out"]);
function runKey(repoKey, runId) {
return `${repoKey}:${runId}`;
}
function isActiveRun(run) {
const status = String(run.status || "").toLowerCase();
if (ACTIVE_STATUSES.has(status)) return true;
const conclusion = String(run.conclusion || "").toLowerCase();
return status === "" && conclusion === "";
}
function isFailedRun(run) {
const conclusion = String(run.conclusion || run.status || "").toLowerCase();
return FAILURE_CONCLUSIONS.has(conclusion);
}
class Store {
constructor() {
this.router = new InstanceRouter();
this.repos = [];
this.instanceStatus = new Map();
this.runs = new Map(); // repoKey -> { runs, legacy, error }
this.jobs = new Map(); // repoKey:runId -> jobs
this.artifacts = new Map(); // repoKey:runId -> artifacts
this.pullRequests = new Map(); // repoKey -> { items, error }
this.pullRequestDetail = new Map(); // repoKey:index -> { reviews, comments, files }
this.secrets = new Map(); // repoKey -> { items, error }
this.variables = new Map(); // repoKey -> { items, error }
this.lastError = null;
this.loading = false;
this.lastRefreshed = null;
this._listeners = new Set();
this._timer = null;
this._seenFailures = new Set();
this._refreshing = null;
this._discovered = false;
}
// -- Change notification ----------------------------------------------
onDidChange(callback) {
this._listeners.add(callback);
return {
dispose: () => {
this._listeners.delete(callback);
},
};
}
notify() {
for (const listener of [...this._listeners]) {
try {
listener(this);
} catch (error) {
log.error("listener failed", String(error));
}
}
}
// -- Lookup -----------------------------------------------------------
repoFor(key) {
return this.repos.find((repo) => repo.key === key) || null;
}
apiFor(repo) {
return this.router.apiFor(repo.baseUrl);
}
/** The branch the Current Branch section should track for `repo`. */
branchFor(repo) {
const filter = config.branchFilter();
if (filter.mode === "all") return null;
if (filter.mode === "custom") return filter.branch;
return repo.branch || null;
}
runsFor(repoKey) {
return this.runs.get(repoKey) || { runs: [], legacy: false, error: null };
}
jobsFor(repoKey, runId) {
return this.jobs.get(runKey(repoKey, runId)) || null;
}
artifactsFor(repoKey, runId) {
return this.artifacts.get(runKey(repoKey, runId)) || null;
}
pullRequestsFor(repoKey) {
return this.pullRequests.get(repoKey) || { items: [], error: null };
}
detailFor(repoKey, index) {
return this.pullRequestDetail.get(`${repoKey}:${index}`) || null;
}
get hasAnyToken() {
return this.router.baseUrls.some((baseUrl) => credentials.hasToken(baseUrl));
}
get hasActiveRuns() {
for (const entry of this.runs.values()) {
if (entry.runs.some(isActiveRun)) return true;
}
return false;
}
// -- Refresh ----------------------------------------------------------
/** Coalesces overlapping refreshes so polling never stacks up. */
refresh(options = {}) {
if (this._refreshing) return this._refreshing;
this._refreshing = this._refresh(options).finally(() => {
this._refreshing = null;
});
return this._refreshing;
}
async _refresh({ rediscover = false } = {}) {
this.loading = true;
this.notify();
try {
if (rediscover || !this._discovered) {
this.router.invalidate();
this.repos = await discovery.discover(this.router);
this._discovered = true;
this._pruneCaches();
} else {
await this._refreshBranches();
}
await this._refreshInstances();
await Promise.all(this.repos.map((repo) => this._refreshRepo(repo)));
this.lastError = null;
this.lastRefreshed = new Date();
} catch (error) {
this.lastError = String(error && error.message ? error.message : error);
log.error("refresh failed", this.lastError);
} finally {
this.loading = false;
this.notify();
this.schedule();
}
}
async refreshRepo(repoKey) {
const repo = this.repoFor(repoKey);
if (!repo) return;
await this._refreshRepo(repo);
this.notify();
}
async _refreshBranches() {
await Promise.all(
this.repos.map(async (repo) => {
if (!repo.dir) return;
repo.branch = await git.currentBranch(repo.dir);
repo.headSha = await git.headSha(repo.dir);
}),
);
}
async _refreshInstances() {
await Promise.all(
this.router.baseUrls.map(async (baseUrl) => {
if (!credentials.hasToken(baseUrl)) {
this.instanceStatus.set(baseUrl, {
ok: false,
error: "No token stored",
needsToken: true,
});
return;
}
const api = this.router.apiFor(baseUrl);
try {
const [version, user] = await Promise.all([api.version(), api.currentUser()]);
this.instanceStatus.set(baseUrl, {
ok: true,
version: version,
user: user ? user.login : null,
error: null,
});
} catch (error) {
this.instanceStatus.set(baseUrl, {
ok: false,
error: String(error.message || error),
needsToken: Boolean(error.isAuth),
});
}
}),
);
}
async _refreshRepo(repo) {
const api = this.apiFor(repo);
if (!api || !credentials.hasToken(repo.baseUrl)) {
this.runs.set(repo.key, { runs: [], legacy: false, error: "No token stored" });
this.pullRequests.set(repo.key, { items: [], error: "No token stored" });
return;
}
await Promise.all([this._refreshRuns(repo, api), this._refreshPullRequests(repo, api)]);
}
async _refreshRuns(repo, api) {
try {
const result = await api.listRuns(repo, { limit: config.maxRuns() });
const previous = this.runs.get(repo.key);
this.runs.set(repo.key, {
runs: result.runs,
legacy: result.legacy,
error: null,
});
this._reportFailures(repo, previous, result.runs);
} catch (error) {
this.runs.set(repo.key, {
runs: [],
legacy: false,
error: String(error.message || error),
});
}
}
async _refreshPullRequests(repo, api) {
try {
const items = await api.listPullRequests(repo, { state: "open", limit: 30 });
this.pullRequests.set(repo.key, { items: items, error: null });
} catch (error) {
this.pullRequests.set(repo.key, {
items: [],
error: String(error.message || error),
});
}
}
/** Loads jobs and artifacts for one run; used when a run row is expanded. */
async loadRunDetail(repo, run) {
const api = this.apiFor(repo);
if (!api) return;
const key = runKey(repo.key, run.id);
const [jobs, artifacts] = await Promise.all([
api.listJobs(repo, run.id, config.maxJobs()).catch((error) => {
log.debug("jobs failed", String(error));
return [];
}),
api.listArtifacts(repo, run.id).catch((error) => {
log.debug("artifacts failed", String(error));
return [];
}),
]);
this.jobs.set(key, jobs);
this.artifacts.set(key, artifacts);
return { jobs, artifacts };
}
/** Loads reviews, review comments, and changed files for one pull request. */
async loadPullRequestDetail(repo, pull) {
const api = this.apiFor(repo);
if (!api) return null;
const [reviewData, files] = await Promise.all([
api.allReviewComments(repo, pull.number).catch((error) => {
log.debug("reviews failed", String(error));
return { reviews: [], comments: [] };
}),
api.pullRequestFiles(repo, pull.number).catch((error) => {
log.debug("files failed", String(error));
return [];
}),
]);
const detail = {
reviews: reviewData.reviews,
comments: reviewData.comments,
files: files,
loadedAt: new Date(),
};
this.pullRequestDetail.set(`${repo.key}:${pull.number}`, detail);
return detail;
}
async loadSecrets(repo) {
const api = this.apiFor(repo);
if (!api) return;
try {
this.secrets.set(repo.key, { items: await api.listSecrets(repo), error: null });
} catch (error) {
this.secrets.set(repo.key, { items: [], error: String(error.message || error) });
}
this.notify();
}
async loadVariables(repo) {
const api = this.apiFor(repo);
if (!api) return;
try {
this.variables.set(repo.key, { items: await api.listVariables(repo), error: null });
} catch (error) {
this.variables.set(repo.key, { items: [], error: String(error.message || error) });
}
this.notify();
}
// -- Housekeeping ------------------------------------------------------
_pruneCaches() {
const live = new Set(this.repos.map((repo) => repo.key));
for (const map of [this.runs, this.pullRequests, this.secrets, this.variables]) {
for (const key of [...map.keys()]) {
if (!live.has(key)) map.delete(key);
}
}
for (const map of [this.jobs, this.artifacts, this.pullRequestDetail]) {
for (const key of [...map.keys()]) {
if (!live.has(key.slice(0, key.lastIndexOf(":")))) map.delete(key);
}
}
}
/** Posts a notification the first time a run is seen in a failed state. */
_reportFailures(repo, previous, runs) {
if (!previous) {
// First load: record current failures without alerting on history.
for (const run of runs) {
if (isFailedRun(run)) this._seenFailures.add(runKey(repo.key, run.id));
}
return;
}
if (!config.notifyFailedRuns()) return;
for (const run of runs) {
const key = runKey(repo.key, run.id);
if (!isFailedRun(run) || this._seenFailures.has(key)) continue;
this._seenFailures.add(key);
this._postFailure(repo, run);
}
}
_postFailure(repo, run) {
try {
const request = new NotificationRequest(`gitea.run-failed.${repo.key}.${run.id}`);
request.title = `${repo.fullName}: run failed`;
request.body = `${run.display_title || run.name || run.path || "Workflow run"} on ${
run.head_branch || "unknown branch"
}`;
request.actions = ["Open Logs", "Open in Browser", "Dismiss"];
nova.notifications.add(request).then((reply) => {
if (reply.actionIdx === 0) {
nova.commands.invoke("gitea.openLatestFailedJobLogs", {
repoKey: repo.key,
runId: run.id,
});
} else if (reply.actionIdx === 1 && run.html_url) {
nova.openURL(run.html_url);
}
});
} catch (error) {
log.debug("notification failed", String(error));
}
}
// -- Polling -----------------------------------------------------------
schedule() {
this.stop();
const seconds = this.hasActiveRuns ? config.runningInterval() : config.idleInterval();
this._timer = setTimeout(() => {
this._timer = null;
this.refresh();
}, Math.max(5, seconds) * 1000);
}
stop() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
}
dispose() {
this.stop();
this._listeners.clear();
}
}
exports.Store = Store;
exports.isActiveRun = isActiveRun;
exports.isFailedRun = isFailedRun;
exports.runKey = runKey;
+127
View File
@@ -0,0 +1,127 @@
//
// Promise wrappers around Nova's callback-based panels, plus the small file
// helpers used to surface logs, diffs, and artifacts in the editor.
//
const log = require("./util/log.js");
function input(message, options = {}) {
return new Promise((resolve) => {
nova.workspace.showInputPanel(message, options, (value) => resolve(value ?? null));
});
}
function palette(message, options = {}) {
return new Promise((resolve) => {
nova.workspace.showInputPalette(message, options, (value) => resolve(value ?? null));
});
}
/**
* Shows a choice palette over `items` and resolves to the chosen item.
* `label` maps an item to its display string.
*/
function choose(items, { placeholder, label = String } = {}) {
return new Promise((resolve) => {
const labels = items.map((item) => label(item));
nova.workspace.showChoicePalette(
labels,
{ placeholder: placeholder },
(value, index) => {
if (value === null || index === null || index === undefined) resolve(null);
else resolve(items[index]);
},
);
});
}
/** Resolves to the index of the chosen button, or null when dismissed. */
function actions(message, buttons) {
return new Promise((resolve) => {
nova.workspace.showActionPanel(message, { buttons: buttons }, (index) =>
resolve(index ?? null),
);
});
}
async function confirm(message, confirmLabel = "OK") {
const index = await actions(message, [confirmLabel, "Cancel"]);
return index === 0;
}
function info(message) {
nova.workspace.showInformativeMessage(message);
}
function warn(message) {
nova.workspace.showWarningMessage(message);
}
function error(message) {
nova.workspace.showErrorMessage(String(message && message.message ? message.message : message));
}
/** Creates `dir` and every missing parent. */
function mkdirp(dir) {
const parts = nova.path.split(nova.path.normalize(dir));
let current = nova.path.isAbsolute(dir) ? "/" : "";
for (const part of parts) {
if (!part || part === "/") continue;
current = nova.path.join(current, part);
if (!nova.fs.access(current, nova.fs.F_OK)) {
nova.fs.mkdir(current);
}
}
}
function writeText(path, contents) {
mkdirp(nova.path.dirname(path));
const file = nova.fs.open(path, "w", "utf-8");
try {
file.write(contents);
} finally {
file.close();
}
return path;
}
function writeBinary(path, buffer) {
mkdirp(nova.path.dirname(path));
const file = nova.fs.open(path, "wb");
try {
file.write(new Uint8Array(buffer));
} finally {
file.close();
}
return path;
}
/**
* Opens `contents` in the editor. When `path` is given the text is saved there
* first so the tab is a real file; otherwise it opens as an untitled document.
*/
async function openText(contents, { path = null, syntax = null } = {}) {
if (path) {
try {
writeText(path, contents);
return await nova.workspace.openFile(path);
} catch (writeError) {
log.error("could not save to", path, String(writeError));
// Fall through to an untitled tab rather than losing the content.
}
}
return await nova.workspace.openNewTextDocument({ content: contents, syntax: syntax });
}
exports.input = input;
exports.palette = palette;
exports.choose = choose;
exports.actions = actions;
exports.confirm = confirm;
exports.info = info;
exports.warn = warn;
exports.error = error;
exports.mkdirp = mkdirp;
exports.writeText = writeText;
exports.writeBinary = writeBinary;
exports.openText = openText;
+127
View File
@@ -0,0 +1,127 @@
//
// Preference access. Every key may be overridden per-workspace; workspace
// values win when they are actually set, otherwise the global value applies.
//
const DEFAULTS = {
"gitea.baseUrl": "https://gitea.com",
"gitea.discovery.mode": "workspace",
"gitea.refresh.runningIntervalSeconds": 15,
"gitea.refresh.idleIntervalSeconds": 60,
"gitea.maxRunsPerRepo": 20,
"gitea.maxJobsPerRun": 50,
"gitea.reviewComments.enabled": true,
"gitea.jobLogs.saveToRepo": true,
"gitea.artifacts.downloadPath": ".tmp/gitea-artifacts/",
"gitea.notifications.failedRuns": true,
"gitea.branchFilter": "current",
};
function isUnset(value) {
return value === null || value === undefined || value === "";
}
function get(key, type) {
let value = null;
if (nova.workspace && nova.workspace.config) {
value = nova.workspace.config.get(key, type);
}
if (isUnset(value)) {
value = nova.config.get(key, type);
}
if (isUnset(value)) {
value = DEFAULTS[key];
}
return isUnset(value) ? null : value;
}
function num(key) {
const value = Number(get(key, "number"));
return Number.isFinite(value) ? value : DEFAULTS[key];
}
function bool(key) {
const value = get(key, "boolean");
return value === null ? DEFAULTS[key] === true : value === true;
}
/** Trailing slashes make every later URL join ambiguous, so strip them once here. */
function normalizeBaseUrl(url) {
if (!url) return null;
let trimmed = String(url).trim().replace(/\/+$/, "");
if (!trimmed) return null;
if (!/^https?:\/\//i.test(trimmed)) trimmed = "https://" + trimmed;
return trimmed;
}
/** The default instance plus any extras, de-duplicated and order-preserving. */
function instances() {
const list = [];
const seen = new Set();
const add = (url) => {
const normalized = normalizeBaseUrl(url);
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
list.push(normalized);
}
};
add(get("gitea.baseUrl", "string"));
const extra =
nova.config.get("gitea.instances", "array") ||
(nova.workspace && nova.workspace.config.get("gitea.instances", "array")) ||
[];
for (const url of extra) add(url);
return list;
}
function setInstances(list) {
const cleaned = [];
const primary = normalizeBaseUrl(get("gitea.baseUrl", "string"));
for (const url of list) {
const normalized = normalizeBaseUrl(url);
if (normalized && normalized !== primary && !cleaned.includes(normalized)) {
cleaned.push(normalized);
}
}
nova.config.set("gitea.instances", cleaned);
}
function branchFilter() {
const custom = get("gitea.branchFilter.custom", "string");
if (!isUnset(custom)) return { mode: "custom", branch: String(custom).trim() };
const mode = get("gitea.branchFilter", "string");
return { mode: mode === "all" ? "all" : "current", branch: null };
}
function setBranchFilter(mode, branch) {
const scope = nova.workspace && nova.workspace.path ? nova.workspace.config : nova.config;
if (mode === "custom") {
scope.set("gitea.branchFilter", "current");
scope.set("gitea.branchFilter.custom", branch);
} else {
scope.set("gitea.branchFilter", mode);
scope.remove("gitea.branchFilter.custom");
}
}
exports.get = get;
exports.num = num;
exports.bool = bool;
exports.instances = instances;
exports.setInstances = setInstances;
exports.normalizeBaseUrl = normalizeBaseUrl;
exports.branchFilter = branchFilter;
exports.setBranchFilter = setBranchFilter;
exports.discoveryMode = () => get("gitea.discovery.mode", "string") || "workspace";
exports.maxRuns = () => num("gitea.maxRunsPerRepo");
exports.maxJobs = () => num("gitea.maxJobsPerRun");
exports.runningInterval = () => num("gitea.refresh.runningIntervalSeconds");
exports.idleInterval = () => num("gitea.refresh.idleIntervalSeconds");
exports.reviewCommentsEnabled = () => bool("gitea.reviewComments.enabled");
exports.saveLogsToRepo = () => bool("gitea.jobLogs.saveToRepo");
exports.notifyFailedRuns = () => bool("gitea.notifications.failedRuns");
exports.artifactDownloadPath = () =>
get("gitea.artifacts.downloadPath", "string") || ".tmp/gitea-artifacts/";
+54
View File
@@ -0,0 +1,54 @@
//
// Promise wrapper around Nova's Process API. Nova has no synchronous exec, so
// everything that shells out (git, mostly) funnels through here.
//
const log = require("./log.js");
function exec(command, args, options = {}) {
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let process;
try {
process = new Process(command, {
args: args,
cwd: options.cwd,
env: options.env,
shell: false,
stdio: "pipe",
});
} catch (error) {
resolve({ status: -1, stdout: "", stderr: String(error) });
return;
}
process.onStdout((line) => {
stdout += line;
});
process.onStderr((line) => {
stderr += line;
});
process.onDidExit((status) => {
log.debug(`exec ${command} ${(args || []).join(" ")} -> ${status}`);
resolve({ status: status, stdout: stdout, stderr: stderr });
});
try {
process.start();
} catch (error) {
resolve({ status: -1, stdout: "", stderr: String(error) });
}
});
}
/** Runs a command and resolves to trimmed stdout, or null on any failure. */
async function output(command, args, options) {
const result = await exec(command, args, options);
if (result.status !== 0) return null;
return result.stdout.trim();
}
exports.exec = exec;
exports.output = output;
+138
View File
@@ -0,0 +1,138 @@
//
// Thin git wrapper. Nova cannot read a repository's state directly, so branch,
// remote, and checkout information all come from the git binary.
//
const { exec, output } = require("./exec.js");
const log = require("./log.js");
const GIT = "/usr/bin/env";
function git(dir, args) {
return exec(GIT, ["git", ...args], { cwd: dir });
}
function gitOutput(dir, args) {
return output(GIT, ["git", ...args], { cwd: dir });
}
async function isRepository(dir) {
const result = await gitOutput(dir, ["rev-parse", "--is-inside-work-tree"]);
return result === "true";
}
async function repositoryRoot(dir) {
return await gitOutput(dir, ["rev-parse", "--show-toplevel"]);
}
async function currentBranch(dir) {
const branch = await gitOutput(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
// A detached HEAD reports "HEAD", which is not a branch anyone can filter by.
return branch && branch !== "HEAD" ? branch : null;
}
async function headSha(dir) {
return await gitOutput(dir, ["rev-parse", "HEAD"]);
}
/** Returns [{ name, url }] for every configured remote. */
async function remotes(dir) {
const text = await gitOutput(dir, ["config", "--get-regexp", "^remote\\..*\\.url$"]);
if (!text) return [];
const found = [];
for (const line of text.split("\n")) {
const match = line.trim().match(/^remote\.(.+)\.url\s+(.+)$/);
if (match) found.push({ name: match[1], url: match[2] });
}
return found;
}
/** Prefers "origin" when present; otherwise the first remote defined. */
async function primaryRemote(dir) {
const list = await remotes(dir);
if (!list.length) return null;
return list.find((remote) => remote.name === "origin") || list[0];
}
async function hasLocalBranch(dir, branch) {
const result = await git(dir, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
return result.status === 0;
}
async function isDirty(dir) {
const text = await gitOutput(dir, ["status", "--porcelain"]);
return Boolean(text && text.length);
}
/**
* Fetches the remote and switches to `branch`, creating a tracking branch when
* it only exists on the remote. Resolves to an error string, or null on success.
*/
async function checkoutBranch(dir, branch, remoteName = "origin") {
if (await isDirty(dir)) {
return "The working tree has uncommitted changes.";
}
const fetched = await git(dir, ["fetch", remoteName, branch]);
if (fetched.status !== 0) {
log.debug("fetch failed", fetched.stderr);
}
if (await hasLocalBranch(dir, branch)) {
const result = await git(dir, ["checkout", branch]);
return result.status === 0 ? null : result.stderr.trim() || "Checkout failed.";
}
const result = await git(dir, [
"checkout",
"-b",
branch,
"--track",
`${remoteName}/${branch}`,
]);
return result.status === 0 ? null : result.stderr.trim() || "Checkout failed.";
}
/**
* Finds git repositories at `root` and one level below it, which covers both a
* single-project workspace and the common "folder of repos" layout.
*/
async function discoverRepositories(root) {
if (!root) return [];
const found = [];
if (await isRepository(root)) {
const top = (await repositoryRoot(root)) || root;
found.push(top);
}
let entries = [];
try {
entries = nova.fs.listdir(root);
} catch (error) {
log.debug("listdir failed", root, String(error));
}
for (const entry of entries) {
if (entry.startsWith(".")) continue;
const child = nova.path.join(root, entry);
const stats = nova.fs.stat(child);
if (!stats || !stats.isDirectory()) continue;
if (!nova.fs.access(nova.path.join(child, ".git"), nova.fs.F_OK)) continue;
if (!found.includes(child)) found.push(child);
}
return found;
}
exports.git = git;
exports.isRepository = isRepository;
exports.repositoryRoot = repositoryRoot;
exports.currentBranch = currentBranch;
exports.headSha = headSha;
exports.remotes = remotes;
exports.primaryRemote = primaryRemote;
exports.checkoutBranch = checkoutBranch;
exports.isDirty = isDirty;
exports.discoverRepositories = discoverRepositories;
+28
View File
@@ -0,0 +1,28 @@
//
// Logging. Debug output is gated behind the gitea.logging.debug preference so
// that a normal session stays quiet in the extension console.
//
const PREFIX = "[gitea]";
function debugEnabled() {
return nova.config.get("gitea.logging.debug", "boolean") === true;
}
exports.debug = function (...args) {
if (debugEnabled()) {
console.log(PREFIX, ...args);
}
};
exports.info = function (...args) {
console.log(PREFIX, ...args);
};
exports.warn = function (...args) {
console.warn(PREFIX, ...args);
};
exports.error = function (...args) {
console.error(PREFIX, ...args);
};
+64
View File
@@ -0,0 +1,64 @@
//
// Timestamp helpers. Gitea returns RFC3339 strings and uses the Go zero time
// ("0001-01-01T00:00:00Z") to mean "never", which must not be shown as a date.
//
const ZERO_YEAR = 1;
function parse(value) {
if (!value) return null;
if (typeof value === "number") {
// Gitea occasionally hands back Unix seconds for job timestamps.
return value > 0 ? new Date(value * 1000) : null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime()) || date.getUTCFullYear() <= ZERO_YEAR) return null;
return date;
}
function relative(value) {
const date = parse(value);
if (!date) return "";
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
const future = seconds < 0;
const abs = Math.abs(seconds);
let text;
if (abs < 45) text = "just now";
else if (abs < 90) text = "a minute";
else if (abs < 3600) text = `${Math.round(abs / 60)}m`;
else if (abs < 86400) text = `${Math.round(abs / 3600)}h`;
else if (abs < 2592000) text = `${Math.round(abs / 86400)}d`;
else if (abs < 31536000) text = `${Math.round(abs / 2592000)}mo`;
else text = `${Math.round(abs / 31536000)}y`;
if (text === "just now") return text;
return future ? `in ${text}` : `${text} ago`;
}
function duration(start, end) {
const from = parse(start);
if (!from) return "";
const to = parse(end) || new Date();
let seconds = Math.max(0, Math.round((to.getTime() - from.getTime()) / 1000));
const hours = Math.floor(seconds / 3600);
seconds -= hours * 3600;
const minutes = Math.floor(seconds / 60);
seconds -= minutes * 60;
if (hours) return `${hours}h ${minutes}m`;
if (minutes) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
function absolute(value) {
const date = parse(value);
if (!date) return "";
return date.toLocaleString();
}
exports.parse = parse;
exports.relative = relative;
exports.duration = duration;
exports.absolute = absolute;
+117
View File
@@ -0,0 +1,117 @@
//
// "Current Branch" section: the pull requests and workflow runs that belong to
// the branch checked out in the workspace (or to the configured branch filter).
//
const builders = require("./builders.js");
const config = require("../util/config.js");
const nodes = require("./nodes.js");
const { NodeProvider } = require("./provider.js");
const { node, message, errorNode } = nodes;
class BranchProvider extends NodeProvider {
roots() {
const store = this.store;
if (!store.router.baseUrls.length) {
return [message("No Gitea instance configured. Open the extension preferences.")];
}
if (!store.hasAnyToken) {
return [message("No token stored. Run “Gitea → Set Token…” to sign in.")];
}
if (!store.repos.length) {
return [
message(
store.loading
? "Loading…"
: "No repository in this workspace points at a configured Gitea instance.",
),
];
}
const filter = config.branchFilter();
const sections = store.repos.map((repo) => this.repoSection(repo, filter));
return store.repos.length === 1 ? sections[0].children : sections;
}
repoSection(repo, filter) {
const branch = this.store.branchFor(repo);
const label =
filter.mode === "all"
? "all branches"
: branch || "no branch (detached HEAD)";
return node("repo", repo.fullName, {
identifier: `branch-repo-${repo.key}`,
description: label,
tooltip: `${repo.htmlUrl}\nBranch filter: ${label}`,
contextValue: "repo",
repo: repo,
data: { repoKey: repo.key },
expanded: true,
children: [this.pullRequestsFolder(repo, branch), this.runsFolder(repo, branch)],
});
}
pullRequestsFolder(repo, branch) {
const { items, error } = this.store.pullRequestsFor(repo.key);
const matching = branch
? items.filter((pull) => pull.head && pull.head.ref === branch)
: items;
let children;
if (error) children = [errorNode(error)];
else if (!matching.length) {
children = [
message(
branch
? `No open pull request from ${branch}.`
: "No open pull requests.",
),
];
} else {
children = matching.map((pull) => builders.pullRequestNode(this.store, repo, pull));
}
return node("prFolder", "Pull Requests", {
identifier: `branch-prs-${repo.key}`,
description: matching.length ? String(matching.length) : "",
contextValue: "prFolder",
repo: repo,
expanded: true,
children: children,
});
}
runsFolder(repo, branch) {
const { runs, error, legacy } = this.store.runsFor(repo.key);
const matching = branch ? runs.filter((run) => run.head_branch === branch) : runs;
let children;
if (error) children = [errorNode(error)];
else if (!matching.length) {
children = [
message(branch ? `No runs for ${branch}.` : "No workflow runs."),
];
} else {
children = matching.map((run) =>
builders.runNode(this.store, repo, run, { showBranch: !branch }),
);
}
return node("runsFolder", legacy ? "Recent Runs" : "Workflow Runs", {
identifier: `branch-runs-${repo.key}`,
description: matching.length ? String(matching.length) : "",
tooltip: legacy
? "This Gitea server predates the workflow runs API; showing recent tasks instead."
: "",
contextValue: "runsFolder",
repo: repo,
expanded: true,
children: children,
});
}
}
exports.BranchProvider = BranchProvider;
+286
View File
@@ -0,0 +1,286 @@
//
// Node builders shared by the Current Branch, Workflows, and Pull Requests
// sections so the same run, job, and pull request rows behave identically
// wherever they appear.
//
const nodes = require("./nodes.js");
const time = require("../util/time.js");
const { isActiveRun } = require("../store.js");
const { node, message } = nodes;
function stepNode(step, index) {
const state = nodes.runState(step);
return node("step", `${nodes.glyphFor(state)} ${step.name || `Step ${index + 1}`}`, {
identifier: `step-${step.number || index}`,
description: time.duration(step.started_at, step.completed_at),
tooltip: `${step.name || ""}\nStatus: ${state}`,
contextValue: "step",
});
}
function jobNode(store, repo, run, job) {
const state = nodes.runState(job);
const steps = Array.isArray(job.steps) ? job.steps : [];
return node("job", `${nodes.glyphFor(state)} ${job.name || `Job ${job.id}`}`, {
identifier: `job-${repo.key}-${job.id}`,
description: nodes.timingText(job),
tooltip: [
job.name,
`Status: ${state}`,
job.runner_name ? `Runner: ${job.runner_name}` : null,
"Double-click to open logs",
]
.filter(Boolean)
.join("\n"),
contextValue: "job",
command: "gitea.viewJobLogs",
repo: repo,
data: { job: job, run: run, repoKey: repo.key },
children: steps.length ? steps.map(stepNode) : null,
});
}
function artifactNode(store, repo, run, artifact) {
const size = artifact.size_in_bytes ? formatBytes(artifact.size_in_bytes) : "";
const expired = artifact.expired ? " · expired" : "";
return node("artifact", `${artifact.name}`, {
identifier: `artifact-${repo.key}-${artifact.id}`,
description: `${size}${expired}`,
tooltip: [
artifact.name,
size ? `Size: ${size}` : null,
artifact.expired ? "This artifact has expired." : null,
"Double-click to download",
]
.filter(Boolean)
.join("\n"),
contextValue: "artifact",
command: "gitea.downloadArtifact",
repo: repo,
data: { artifact: artifact, run: run, repoKey: repo.key },
});
}
function formatBytes(bytes) {
const units = ["B", "KB", "MB", "GB"];
let value = Number(bytes);
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
}
/** A run row. Jobs and artifacts load only when the row is expanded. */
function runNode(store, repo, run, { showBranch = true } = {}) {
const state = nodes.runState(run);
const subtitle = [];
if (showBranch && run.head_branch) subtitle.push(run.head_branch);
if (run.event) subtitle.push(run.event);
const timing = nodes.timingText(run);
if (timing) subtitle.push(timing);
return node("run", `${nodes.glyphFor(state)} ${nodes.runTitle(run)}`, {
identifier: `run-${repo.key}-${run.id}`,
description: subtitle.join(" · "),
tooltip: [
nodes.runTitle(run),
`Status: ${state}`,
run.head_branch ? `Branch: ${run.head_branch}` : null,
run.head_sha ? `Commit: ${String(run.head_sha).slice(0, 8)}` : null,
run.run_number ? `Run #${run.run_number}` : null,
run.actor && run.actor.login ? `Actor: ${run.actor.login}` : null,
]
.filter(Boolean)
.join("\n"),
contextValue: "run",
repo: repo,
data: { run: run, repoKey: repo.key },
load: async () => {
// Reuse cached jobs so a periodic tree reload does not refetch every
// expanded run; a run that is still going always refreshes.
const cachedJobs = store.jobsFor(repo.key, run.id);
const active = isActiveRun(run);
const detail =
cachedJobs && !active
? { jobs: cachedJobs, artifacts: store.artifactsFor(repo.key, run.id) || [] }
: await store.loadRunDetail(repo, run);
if (!detail) return [message("Unable to load run details.")];
const children = detail.jobs.map((job) => jobNode(store, repo, run, job));
if (!children.length) children.push(message("No jobs reported."));
if (detail.artifacts.length) {
children.push(
node("artifactsFolder", "Artifacts", {
identifier: `artifacts-${repo.key}-${run.id}`,
description: String(detail.artifacts.length),
contextValue: "artifactsFolder",
repo: repo,
children: detail.artifacts.map((artifact) =>
artifactNode(store, repo, run, artifact),
),
}),
);
}
return children;
},
});
}
function reviewCommentNode(store, repo, pull, comment) {
const author = (comment.user && comment.user.login) || "someone";
const location = comment.path
? `${comment.path}${comment.position ? `:${comment.position}` : ""}`
: "";
const resolved = comment.resolver && comment.resolver.login ? "✓ " : "";
return node("comment", `${resolved}${author}: ${firstLine(comment.body)}`, {
identifier: `comment-${comment.id}`,
description: location,
tooltip: [location, comment.body, comment.diff_hunk].filter(Boolean).join("\n\n"),
contextValue: "comment",
repo: repo,
command: "gitea.openReviewComment",
data: { comment: comment, pull: pull, repoKey: repo.key },
});
}
function reviewNode(store, repo, pull, review, comments) {
const author = (review.user && review.user.login) || "someone";
const own = comments.filter((comment) => comment.pull_request_review_id === review.id);
return node("review", `${nodes.reviewGlyph(review.state)} ${author}`, {
identifier: `review-${review.id}`,
description: `${String(review.state || "").toLowerCase()} · ${time.relative(
review.submitted_at || review.updated_at,
)}`,
tooltip: [review.body, `State: ${review.state}`].filter(Boolean).join("\n\n"),
contextValue: "review",
repo: repo,
data: { review: review, pull: pull, repoKey: repo.key },
children: own.length
? own.map((comment) => reviewCommentNode(store, repo, pull, comment))
: null,
});
}
function changedFileNode(store, repo, pull, file) {
const marker =
file.status === "added" ? "+" : file.status === "deleted" ? "" : file.status === "renamed" ? "→" : "±";
return node("prFile", `${marker} ${file.filename}`, {
identifier: `prfile-${pull.number}-${file.filename}`,
description: `+${file.additions || 0} ${file.deletions || 0}`,
tooltip: `${file.filename}\nStatus: ${file.status}`,
contextValue: "prFile",
repo: repo,
command: "gitea.openChangedFile",
data: { file: file, pull: pull, repoKey: repo.key },
});
}
/** A pull request row; reviews, comments, and files load on expand. */
function pullRequestNode(store, repo, pull, { showRepo = false } = {}) {
const subtitle = [];
if (showRepo) subtitle.push(repo.fullName);
if (pull.head && pull.head.ref) subtitle.push(`${pull.head.ref}${pull.base.ref}`);
subtitle.push(time.relative(pull.updated_at));
return node("pr", `${nodes.pullRequestGlyph(pull)} #${pull.number} ${pull.title}`, {
identifier: `pr-${repo.key}-${pull.number}`,
description: subtitle.filter(Boolean).join(" · "),
tooltip: [
`#${pull.number} ${pull.title}`,
pull.user ? `Author: ${pull.user.login}` : null,
pull.head && pull.base ? `${pull.head.label}${pull.base.label}` : null,
pull.mergeable === false ? "Has conflicts" : null,
pull.draft ? "Draft" : null,
]
.filter(Boolean)
.join("\n"),
contextValue: "pr",
repo: repo,
data: { pull: pull, repoKey: repo.key },
load: async () => {
// Cached detail is reused until the pull request itself changes.
const cached = store.detailFor(repo.key, pull.number);
const fresh =
cached && cached.loadedAt >= new Date(pull.updated_at || 0) ? cached : null;
const detail = fresh || (await store.loadPullRequestDetail(repo, pull));
if (!detail) return [message("Unable to load pull request details.")];
const children = [];
if (detail.reviews.length) {
children.push(
node("reviewsFolder", "Reviews", {
identifier: `reviews-${repo.key}-${pull.number}`,
description: String(detail.reviews.length),
contextValue: "reviewsFolder",
repo: repo,
children: detail.reviews.map((review) =>
reviewNode(store, repo, pull, review, detail.comments),
),
}),
);
}
const orphans = detail.comments.filter(
(comment) =>
!detail.reviews.some(
(review) => review.id === comment.pull_request_review_id,
),
);
if (orphans.length) {
children.push(
node("commentsFolder", "Comments", {
identifier: `comments-${repo.key}-${pull.number}`,
description: String(orphans.length),
contextValue: "commentsFolder",
repo: repo,
children: orphans.map((comment) =>
reviewCommentNode(store, repo, pull, comment),
),
}),
);
}
if (detail.files.length) {
children.push(
node("filesFolder", "Changed Files", {
identifier: `files-${repo.key}-${pull.number}`,
description: String(detail.files.length),
contextValue: "filesFolder",
repo: repo,
children: detail.files.map((file) =>
changedFileNode(store, repo, pull, file),
),
}),
);
}
return children.length ? children : [message("No reviews or changed files.")];
},
});
}
function firstLine(text) {
const line = String(text || "").split("\n").find((entry) => entry.trim());
if (!line) return "(empty comment)";
return line.length > 80 ? `${line.slice(0, 77)}` : line.trim();
}
exports.runNode = runNode;
exports.jobNode = jobNode;
exports.artifactNode = artifactNode;
exports.pullRequestNode = pullRequestNode;
exports.reviewCommentNode = reviewCommentNode;
exports.changedFileNode = changedFileNode;
exports.formatBytes = formatBytes;
exports.firstLine = firstLine;
+135
View File
@@ -0,0 +1,135 @@
//
// Node model shared by every sidebar section. Nova's TreeItem has no badge or
// status colour, so run state is carried by a leading glyph in the item name
// and by descriptive text on the trailing edge.
//
const time = require("../util/time.js");
const RUN_GLYPHS = {
success: "✓",
failure: "✗",
failed: "✗",
error: "✗",
timed_out: "✗",
cancelled: "⊘",
canceled: "⊘",
skipped: "⊝",
running: "●",
in_progress: "●",
queued: "◌",
waiting: "◌",
pending: "◌",
blocked: "◌",
};
function glyphFor(statusOrConclusion) {
const key = String(statusOrConclusion || "").toLowerCase();
return RUN_GLYPHS[key] || "•";
}
/** A completed run reports state via `conclusion`; a live one via `status`. */
function runState(run) {
const conclusion = String(run.conclusion || "").toLowerCase();
if (conclusion) return conclusion;
return String(run.status || "").toLowerCase() || "unknown";
}
function pullRequestGlyph(pull) {
if (pull.merged) return "⤳";
if (pull.state === "closed") return "⊘";
if (pull.draft) return "◌";
return "◆";
}
function reviewGlyph(state) {
const key = String(state || "").toUpperCase();
if (key === "APPROVED") return "✓";
if (key === "REQUEST_CHANGES" || key === "CHANGES_REQUESTED") return "✗";
if (key === "PENDING") return "◌";
return "◆";
}
let counter = 0;
/**
* Builds a tree node. `children` is a resolved array; `load` is an async
* producer used for rows that fetch on expand.
*/
function node(kind, name, options = {}) {
counter += 1;
const value = {
kind: kind,
name: name,
identifier: options.identifier || `${kind}-${counter}`,
descriptiveText: options.description || "",
tooltip: options.tooltip || "",
image: options.image || null,
contextValue: options.contextValue || kind,
command: options.command || null,
path: options.path || null,
data: options.data || null,
repo: options.repo || null,
children: options.children || null,
load: options.load || null,
expanded: options.expanded === true,
parent: null,
};
if (value.children) {
for (const child of value.children) child.parent = value;
}
return value;
}
function message(text, kind = "message") {
return node(kind, text, { contextValue: "message" });
}
function errorNode(text) {
return node("error", `${text}`, { contextValue: "message", tooltip: text });
}
/** Formats "12s · 3m ago" style trailing text for a run or job. */
function timingText(entity) {
const parts = [];
const length = time.duration(entity.started_at, entity.completed_at);
if (length) parts.push(length);
const when = time.relative(entity.started_at || entity.created_at || entity.updated_at);
if (when) parts.push(when);
return parts.join(" · ");
}
function runTitle(run) {
return (
run.display_title ||
run.title ||
run.name ||
(run.path ? run.path.split("/").pop() : null) ||
`Run #${run.run_number || run.id}`
);
}
/** Groups runs by their workflow file, falling back to the run's own name. */
function workflowKey(run) {
if (run.path) return run.path;
if (run.workflow_id) return String(run.workflow_id);
return run.name || "Workflow";
}
function workflowLabel(run) {
if (run.path) return run.path.split("/").pop();
return run.name || "Workflow";
}
exports.node = node;
exports.message = message;
exports.errorNode = errorNode;
exports.glyphFor = glyphFor;
exports.runState = runState;
exports.pullRequestGlyph = pullRequestGlyph;
exports.reviewGlyph = reviewGlyph;
exports.timingText = timingText;
exports.runTitle = runTitle;
exports.workflowKey = workflowKey;
exports.workflowLabel = workflowLabel;
+79
View File
@@ -0,0 +1,79 @@
//
// Generic TreeDataProvider over the node model. Subclasses only implement
// roots(); everything else — lazy children, TreeItem mapping — happens here.
//
const log = require("../util/log.js");
const { message } = require("./nodes.js");
class NodeProvider {
constructor(store) {
this.store = store;
this._roots = null;
}
/** Subclasses return an array of nodes (or a Promise of one). */
roots() {
return [];
}
/** Drops memoised roots so the next getChildren rebuilds from the store. */
invalidate() {
this._roots = null;
}
async getChildren(element) {
try {
if (!element) {
if (!this._roots) {
this._roots = await this.roots();
// A section may hoist a repository's children to the top
// level; those nodes must not keep a parent Nova cannot see.
for (const root of this._roots) root.parent = null;
}
return this._roots;
}
if (element.children) return element.children;
if (element.load) {
const children = await element.load();
element.children = children || [];
for (const child of element.children) child.parent = element;
return element.children;
}
return [];
} catch (error) {
log.error("tree build failed", String(error && error.stack ? error.stack : error));
return [message(`${error.message || error}`)];
}
}
getParent(element) {
return element ? element.parent : null;
}
getTreeItem(element) {
const collapsible =
element.children || element.load
? element.expanded
? TreeItemCollapsibleState.Expanded
: TreeItemCollapsibleState.Collapsed
: TreeItemCollapsibleState.None;
const item = new TreeItem(element.name, collapsible);
item.identifier = element.identifier;
item.contextValue = element.contextValue;
if (element.descriptiveText) item.descriptiveText = element.descriptiveText;
if (element.tooltip) item.tooltip = element.tooltip;
if (element.image) item.image = element.image;
if (element.path) item.path = element.path;
if (element.command) item.command = element.command;
return item;
}
}
exports.NodeProvider = NodeProvider;
+48
View File
@@ -0,0 +1,48 @@
//
// "Pull Requests" section: open pull requests across every discovered
// repository, each expanding into its reviews, comments, and changed files.
//
const builders = require("./builders.js");
const nodes = require("./nodes.js");
const { NodeProvider } = require("./provider.js");
const { node, message, errorNode } = nodes;
class PullRequestsProvider extends NodeProvider {
roots() {
const store = this.store;
if (!store.hasAnyToken) {
return [message("No token stored. Run “Gitea → Set Token…” to sign in.")];
}
if (!store.repos.length) {
return [message(store.loading ? "Loading…" : "No Gitea repositories found.")];
}
const sections = store.repos.map((repo) => this.repoSection(repo));
return store.repos.length === 1 ? sections[0].children : sections;
}
repoSection(repo) {
const { items, error } = this.store.pullRequestsFor(repo.key);
let children;
if (error) children = [errorNode(error)];
else if (!items.length) children = [message("No open pull requests.")];
else children = items.map((pull) => builders.pullRequestNode(this.store, repo, pull));
return node("repo", repo.fullName, {
identifier: `pr-repo-${repo.key}`,
description: items.length ? String(items.length) : "",
tooltip: repo.htmlUrl,
contextValue: "repo",
repo: repo,
data: { repoKey: repo.key },
expanded: true,
children: children,
});
}
}
exports.PullRequestsProvider = PullRequestsProvider;
+158
View File
@@ -0,0 +1,158 @@
//
// "Settings" section: instance connection state plus per-repository Actions
// secrets and variables. Secrets and variables load only when expanded, since
// both endpoints need scopes a read-only token may not carry.
//
const credentials = require("../gitea/credentials.js");
const nodes = require("./nodes.js");
const time = require("../util/time.js");
const { NodeProvider } = require("./provider.js");
const { node, message, errorNode } = nodes;
class SettingsProvider extends NodeProvider {
roots() {
const store = this.store;
const baseUrls = store.router.baseUrls;
if (!baseUrls.length) {
return [message("No Gitea instance configured. Open the extension preferences.")];
}
const instances = baseUrls.map((baseUrl) => this.instanceNode(baseUrl));
if (!store.repos.length) return instances;
return [
...instances,
node("reposFolder", "Repositories", {
identifier: "settings-repos",
description: String(store.repos.length),
contextValue: "reposFolder",
expanded: true,
children: store.repos.map((repo) => this.repoNode(repo)),
}),
];
}
instanceNode(baseUrl) {
const status = this.store.instanceStatus.get(baseUrl);
const hasToken = credentials.hasToken(baseUrl);
let glyph = "◌";
let description = "not checked";
if (status && status.ok) {
glyph = "✓";
description = [status.user ? `@${status.user}` : null, `v${status.version}`]
.filter(Boolean)
.join(" · ");
} else if (status) {
glyph = hasToken ? "✗" : "○";
description = hasToken ? "error" : "signed out";
}
const children = [];
if (status && status.error) children.push(errorNode(status.error));
if (!hasToken) {
children.push(
node("action", "Set Token…", {
identifier: `settings-settoken-${baseUrl}`,
contextValue: "message",
command: "gitea.setToken",
data: { baseUrl: baseUrl },
}),
);
}
return node("instance", `${glyph} ${baseUrl.replace(/^https?:\/\//, "")}`, {
identifier: `settings-instance-${baseUrl}`,
description: description,
tooltip: `${baseUrl}\n${
status && status.ok
? `Connected as ${status.user || "unknown"} (Gitea ${status.version})`
: status && status.error
? status.error
: "Not checked yet"
}`,
contextValue: "instance",
data: { baseUrl: baseUrl },
children: children.length ? children : null,
});
}
repoNode(repo) {
return node("repo", repo.fullName, {
identifier: `settings-repo-${repo.key}`,
description: repo.baseUrl.replace(/^https?:\/\//, ""),
tooltip: [repo.htmlUrl, repo.dir ? `Local: ${repo.dir}` : null]
.filter(Boolean)
.join("\n"),
contextValue: "repo",
repo: repo,
data: { repoKey: repo.key },
children: [this.secretsFolder(repo), this.variablesFolder(repo)],
});
}
secretsFolder(repo) {
const cached = this.store.secrets.get(repo.key);
return node("secretsFolder", "Secrets", {
identifier: `settings-secrets-${repo.key}`,
description: cached && !cached.error ? String(cached.items.length) : "",
contextValue: "secretsFolder",
repo: repo,
data: { repoKey: repo.key },
load: async () => {
if (!this.store.secrets.has(repo.key)) await this.store.loadSecrets(repo);
const entry = this.store.secrets.get(repo.key);
if (!entry) return [message("Unavailable.")];
if (entry.error) return [errorNode(entry.error)];
if (!entry.items.length) return [message("No secrets defined.")];
return entry.items.map((secret) =>
node("secret", `🔒 ${secret.name}`, {
identifier: `secret-${repo.key}-${secret.name}`,
description: time.relative(secret.created_at),
tooltip: secret.description || secret.name,
contextValue: "secret",
repo: repo,
data: { secret: secret, repoKey: repo.key },
}),
);
},
});
}
variablesFolder(repo) {
const cached = this.store.variables.get(repo.key);
return node("variablesFolder", "Variables", {
identifier: `settings-variables-${repo.key}`,
description: cached && !cached.error ? String(cached.items.length) : "",
contextValue: "variablesFolder",
repo: repo,
data: { repoKey: repo.key },
load: async () => {
if (!this.store.variables.has(repo.key)) await this.store.loadVariables(repo);
const entry = this.store.variables.get(repo.key);
if (!entry) return [message("Unavailable.")];
if (entry.error) return [errorNode(entry.error)];
if (!entry.items.length) return [message("No variables defined.")];
return entry.items.map((variable) =>
node("variable", variable.name, {
identifier: `variable-${repo.key}-${variable.name}`,
description: variable.data || variable.value || "",
tooltip: variable.description || variable.name,
contextValue: "variable",
repo: repo,
data: { variable: variable, repoKey: repo.key },
}),
);
},
});
}
}
exports.SettingsProvider = SettingsProvider;
+73
View File
@@ -0,0 +1,73 @@
//
// "Workflows" section: every run in the repository, grouped by workflow file.
// Servers without the workflow runs API fall back to a flat "Recent Runs" list.
//
const builders = require("./builders.js");
const nodes = require("./nodes.js");
const { NodeProvider } = require("./provider.js");
const { node, message, errorNode } = nodes;
class WorkflowsProvider extends NodeProvider {
roots() {
const store = this.store;
if (!store.hasAnyToken) {
return [message("No token stored. Run “Gitea → Set Token…” to sign in.")];
}
if (!store.repos.length) {
return [message(store.loading ? "Loading…" : "No Gitea repositories found.")];
}
const sections = store.repos.map((repo) => this.repoSection(repo));
return store.repos.length === 1 ? sections[0].children : sections;
}
repoSection(repo) {
const { runs, error, legacy } = this.store.runsFor(repo.key);
let children;
if (error) children = [errorNode(error)];
else if (!runs.length) children = [message("No workflow runs.")];
else if (legacy) children = runs.map((run) => builders.runNode(this.store, repo, run));
else children = this.groupByWorkflow(repo, runs);
return node("repo", repo.fullName, {
identifier: `wf-repo-${repo.key}`,
description: legacy ? "recent runs" : `${runs.length} runs`,
tooltip: repo.htmlUrl,
contextValue: "repo",
repo: repo,
data: { repoKey: repo.key },
expanded: true,
children: children,
});
}
groupByWorkflow(repo, runs) {
const groups = new Map();
for (const run of runs) {
const key = nodes.workflowKey(run);
if (!groups.has(key)) {
groups.set(key, { label: nodes.workflowLabel(run), runs: [] });
}
groups.get(key).runs.push(run);
}
return [...groups.entries()].map(([key, group]) => {
const latest = group.runs[0];
return node("workflow", group.label, {
identifier: `wf-${repo.key}-${key}`,
description: `${nodes.glyphFor(nodes.runState(latest))} ${group.runs.length}`,
tooltip: `${key}\nMost recent: ${nodes.runTitle(latest)}`,
contextValue: "workflow",
repo: repo,
data: { workflowPath: key, run: latest, repoKey: repo.key },
children: group.runs.map((run) => builders.runNode(this.store, repo, run)),
});
});
}
}
exports.WorkflowsProvider = WorkflowsProvider;