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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user