commit 694609a3ccffaaad2d2231ac5f91f97641af9c91 Author: James Griffin Date: Fri Aug 28 19:55:54 2026 -0300 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 Claude-Session: https://claude.ai/code/session_01MQuusXgZC2dzwpJJ1qhtti diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f2e848 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..875657c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +## Version 1.0 + +Initial release. + +- **Current Branch** sidebar section with the pull requests and workflow runs for + the checked-out branch, plus a configurable branch filter. +- **Workflows** section grouping every run by workflow file, expanding into jobs, + steps, and artifacts. Servers older than Gitea 1.24 fall back to a flat recent + runs list. +- **Pull Requests** section across every discovered repository, expanding into + reviews, review comments, and changed files. +- **Settings** section showing per-instance connection state and per-repository + Actions secrets and variables. +- Run control: re-run a run, re-run failed jobs, re-run a single job, cancel. +- Job logs opened in the editor, optionally saved under `.tmp/gitea-logs/`. +- Artifact download, reveal in Finder, and open. +- Pull request Markdown overview, unified diff, per-file diff, branch checkout, + creation, merge with all four Gitea strategies, and close. +- Reviews: approve, request changes, comment, reply, resolve, unresolve, and + adding a comment on the line under the cursor from the editor. +- Review comments for the current branch published as editor issues. +- Multiple Gitea instances, routed by git remote host, with tokens stored per + instance in the macOS Keychain. +- Adaptive polling and a notification when a workflow run fails. diff --git a/Images/branch/branch.png b/Images/branch/branch.png new file mode 100644 index 0000000..d48a1fc Binary files /dev/null and b/Images/branch/branch.png differ diff --git a/Images/branch/branch@2x.png b/Images/branch/branch@2x.png new file mode 100644 index 0000000..792fe74 Binary files /dev/null and b/Images/branch/branch@2x.png differ diff --git a/Images/branch/metadata.json b/Images/branch/metadata.json new file mode 100644 index 0000000..e894d75 --- /dev/null +++ b/Images/branch/metadata.json @@ -0,0 +1,3 @@ +{ + "template": true +} diff --git a/Images/extension/extension.png b/Images/extension/extension.png new file mode 100644 index 0000000..a941e48 Binary files /dev/null and b/Images/extension/extension.png differ diff --git a/Images/extension/extension@2x.png b/Images/extension/extension@2x.png new file mode 100644 index 0000000..6120684 Binary files /dev/null and b/Images/extension/extension@2x.png differ diff --git a/Images/extension/metadata.json b/Images/extension/metadata.json new file mode 100644 index 0000000..e894d75 --- /dev/null +++ b/Images/extension/metadata.json @@ -0,0 +1,3 @@ +{ + "template": true +} diff --git a/Images/gitea-large/gitea-large.png b/Images/gitea-large/gitea-large.png new file mode 100644 index 0000000..a941e48 Binary files /dev/null and b/Images/gitea-large/gitea-large.png differ diff --git a/Images/gitea-large/gitea-large@2x.png b/Images/gitea-large/gitea-large@2x.png new file mode 100644 index 0000000..6120684 Binary files /dev/null and b/Images/gitea-large/gitea-large@2x.png differ diff --git a/Images/gitea-large/metadata.json b/Images/gitea-large/metadata.json new file mode 100644 index 0000000..e894d75 --- /dev/null +++ b/Images/gitea-large/metadata.json @@ -0,0 +1,3 @@ +{ + "template": true +} diff --git a/Images/gitea-small/gitea-small.png b/Images/gitea-small/gitea-small.png new file mode 100644 index 0000000..6b9d986 Binary files /dev/null and b/Images/gitea-small/gitea-small.png differ diff --git a/Images/gitea-small/gitea-small@2x.png b/Images/gitea-small/gitea-small@2x.png new file mode 100644 index 0000000..a941e48 Binary files /dev/null and b/Images/gitea-small/gitea-small@2x.png differ diff --git a/Images/gitea-small/metadata.json b/Images/gitea-small/metadata.json new file mode 100644 index 0000000..e894d75 --- /dev/null +++ b/Images/gitea-small/metadata.json @@ -0,0 +1,3 @@ +{ + "template": true +} diff --git a/Images/refresh/metadata.json b/Images/refresh/metadata.json new file mode 100644 index 0000000..e894d75 --- /dev/null +++ b/Images/refresh/metadata.json @@ -0,0 +1,3 @@ +{ + "template": true +} diff --git a/Images/refresh/refresh.png b/Images/refresh/refresh.png new file mode 100644 index 0000000..c9c716c Binary files /dev/null and b/Images/refresh/refresh.png differ diff --git a/Images/refresh/refresh@2x.png b/Images/refresh/refresh@2x.png new file mode 100644 index 0000000..c8ae332 Binary files /dev/null and b/Images/refresh/refresh@2x.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec3c287 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Gitea for Nova + +Gitea Actions, pull requests, reviews, secrets, and variables in the Nova +sidebar. This extension aims to give Nova the same day-to-day coverage the +official [Gitea for VS Code](https://gitea.com/gitea/gitea-vscode) extension +gives VS Code. + +## Requirements + +- Nova 9 or later, on macOS. +- Gitea 1.24 or later for the workflow runs API. Older servers still work: the + extension falls back to `/actions/tasks` and shows a flat **Recent Runs** list. +- A personal access token, created in Gitea under **Settings → Applications → + Access Tokens**. + +### Token scopes + +| Scope | Needed for | +| --- | --- | +| `read:repository` | Repositories, branches, pull requests | +| `read:issue` | Pull request timelines and comments | +| `write:repository` | Approving, merging, closing, review comments | +| `read:user` | Identifying the signed-in account | +| Actions read | Workflow runs, jobs, logs, artifacts | +| Actions write | Re-running and cancelling runs | +| Secrets / variables | Managing repository secrets and variables | + +Read-only tokens work fine; the write-only features simply report a clear +permission error when used. + +## Getting started + +1. Open **Extensions → Gitea → Open Gitea Preferences** and set your instance + URL (it defaults to `https://gitea.com`). +2. Run **Extensions → Gitea → Set Token…** and paste a personal access token. + Tokens are stored in the macOS Keychain, one per instance. +3. Open the **Gitea** sidebar. + +Repositories are discovered from the git remotes of the folders in your +workspace. A remote is matched to an instance by its host, so +`git@gitea.example.com:acme/widget.git` routes to the configured instance at +`https://gitea.example.com`. Set **Discovery Mode** to *All Accessible +Repositories* to list everything your token can reach instead. + +## Sidebar sections + +**Current Branch** — pull requests and workflow runs for the branch checked out +in the workspace. Use the branch button in the section header (or **Change +Branch Filter…**) to follow all branches or pin a specific one. + +**Workflows** — every run in the repository, grouped by workflow file. Expand a +run for its jobs, each job for its steps, and open the run's artifacts. + +**Pull Requests** — open pull requests across all discovered repositories, each +expanding into its reviews, review comments, and changed files. + +**Settings** — connection state per instance, plus repository Actions secrets +and variables. Secrets and variables load only when you expand them. + +## What you can do + +- **Runs** — re-run a run, re-run only its failed jobs, re-run a single job, or + cancel a run in progress. +- **Logs** — double-click a job to open its logs. With **Save Job Logs to + Repository** on, logs are written to `.tmp/gitea-logs/` so they open as real + files; otherwise they open in an untitled tab. +- **Artifacts** — download to `.tmp/gitea-artifacts/`, reveal in Finder, or open + (single-file artifacts are unzipped and opened directly). +- **Pull requests** — open a Markdown overview, open the unified diff, jump to a + changed file, check out the branch, create a pull request, merge with any of + Gitea's four strategies, or close. +- **Reviews** — approve, request changes, or comment; reply to a review comment; + resolve and unresolve threads. +- **Inline comments** — review comments on the current branch's pull requests + appear as issues in the editor gutter and the Issues sidebar, anchored to the + line the comment was left on. Comments from a *request changes* review show as + errors, others as warnings, resolved ones as info. +- **Add a review comment from the editor** — put the cursor on a line and use + **Editor → Add Gitea Review Comment…**. +- **Secrets and variables** — add, update, and delete, per repository. + +Polling adapts to activity: every 15 seconds while runs are queued or running, +every 60 seconds otherwise. Both intervals are configurable, and a run that +finishes in a failed state posts a notification. + +## Multiple instances + +Add extra instances under **Additional Instances** in preferences, or with +**Add Gitea Instance…**. Each keeps its own Keychain token, and repositories +route to whichever instance matches their remote host. The default instance is +used for anything that does not match. + +## Differences from the VS Code extension + +Nova's extension API does not expose everything VS Code's does, so a few +features are shaped differently: + +| Gitea for VS Code | Here | +| --- | --- | +| Webview pull request timeline | Markdown overview opened in an editor tab | +| Native side-by-side diff editor | Unified `.diff` document, per file or whole PR | +| Inline comment threads on the diff | Review comments published as editor issues | +| Status bar run summary | Notification when a run fails | +| OAuth sign-in | Personal access tokens only — Nova extensions cannot run the local HTTP listener an OAuth redirect needs | +| `tls.insecureSkipVerify` | Not available; Nova's `fetch` has no TLS override | + +## Preferences + +| Setting | Default | What it does | +| --- | --- | --- | +| Default Instance URL | `https://gitea.com` | Instance used when no other matches a remote | +| Additional Instances | — | Extra instance URLs | +| Discovery Mode | `workspace` | Workspace git remotes, or all accessible repositories | +| Max Runs per Repository | 20 | How many runs to load | +| Max Jobs per Run | 50 | How many jobs to load | +| Active Interval | 15s | Poll rate while runs are in progress | +| Idle Interval | 60s | Poll rate when nothing is running | +| Notify on Failed Runs | on | Post a notification when a run fails | +| Show Review Comments Inline | on | Publish review comments as editor issues | +| Save Job Logs to Repository | on | Write logs to `.tmp/gitea-logs/` | +| Artifact Download Path | `.tmp/gitea-artifacts/` | Where artifacts land | +| Enable Debug Logging | off | Verbose extension console output | + +Instance URL and branch filter can also be set per workspace under **Project +Settings**. + +You will probably want to add `.tmp/` to the repository's `.gitignore`. + +## Development + +Symlink or copy this folder into Nova's extension directory and open the +**Extensions → Extension Library** to load it: + +```sh +ln -s "$PWD" ~/Library/Application\ Support/Nova/Extensions/unsupervised.giteanova +``` + +Turn on **Enable Debug Logging** and watch the extension console +(**Extensions → Extension Library → Gitea → Console**) while working. + +### Tests + +```sh +Tests/run.sh +``` + +Two suites run the extension's real code under plain Node against a stubbed Nova +runtime and a canned Gitea instance — no install step, no network, and nothing +written inside the repository. See [Tests/README.md](Tests/README.md). + +### Images + +`Images/` is generated. To change an icon, edit the shapes in +`Tools/make-icons.py` and re-run it from the extension root: + +```sh +python3 Tools/make-icons.py +``` + +## License + +MIT diff --git a/Scripts/controllers/artifacts.js b/Scripts/controllers/artifacts.js new file mode 100644 index 0000000..3570646 --- /dev/null +++ b/Scripts/controllers/artifacts.js @@ -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; diff --git a/Scripts/controllers/auth.js b/Scripts/controllers/auth.js new file mode 100644 index 0000000..ee7707d --- /dev/null +++ b/Scripts/controllers/auth.js @@ -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; diff --git a/Scripts/controllers/browser.js b/Scripts/controllers/browser.js new file mode 100644 index 0000000..486ee77 --- /dev/null +++ b/Scripts/controllers/browser.js @@ -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; diff --git a/Scripts/controllers/logs.js b/Scripts/controllers/logs.js new file mode 100644 index 0000000..993ac94 --- /dev/null +++ b/Scripts/controllers/logs.js @@ -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; diff --git a/Scripts/controllers/pullRequests.js b/Scripts/controllers/pullRequests.js new file mode 100644 index 0000000..b400aa3 --- /dev/null +++ b/Scripts/controllers/pullRequests.js @@ -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; diff --git a/Scripts/controllers/review.js b/Scripts/controllers/review.js new file mode 100644 index 0000000..b6b33ea --- /dev/null +++ b/Scripts/controllers/review.js @@ -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; diff --git a/Scripts/controllers/reviewComments.js b/Scripts/controllers/reviewComments.js new file mode 100644 index 0000000..dd5d52d --- /dev/null +++ b/Scripts/controllers/reviewComments.js @@ -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; diff --git a/Scripts/controllers/runControl.js b/Scripts/controllers/runControl.js new file mode 100644 index 0000000..1815d0b --- /dev/null +++ b/Scripts/controllers/runControl.js @@ -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; diff --git a/Scripts/controllers/secretsVariables.js b/Scripts/controllers/secretsVariables.js new file mode 100644 index 0000000..d99fac2 --- /dev/null +++ b/Scripts/controllers/secretsVariables.js @@ -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; diff --git a/Scripts/controllers/selection.js b/Scripts/controllers/selection.js new file mode 100644 index 0000000..d7760bb --- /dev/null +++ b/Scripts/controllers/selection.js @@ -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; diff --git a/Scripts/gitea/api.js b/Scripts/gitea/api.js new file mode 100644 index 0000000..5b05dca --- /dev/null +++ b/Scripts/gitea/api.js @@ -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; diff --git a/Scripts/gitea/client.js b/Scripts/gitea/client.js new file mode 100644 index 0000000..7924157 --- /dev/null +++ b/Scripts/gitea/client.js @@ -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; diff --git a/Scripts/gitea/credentials.js b/Scripts/gitea/credentials.js new file mode 100644 index 0000000..2bfdf4c --- /dev/null +++ b/Scripts/gitea/credentials.js @@ -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; diff --git a/Scripts/gitea/discovery.js b/Scripts/gitea/discovery.js new file mode 100644 index 0000000..61ef3d7 --- /dev/null +++ b/Scripts/gitea/discovery.js @@ -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; diff --git a/Scripts/gitea/remotes.js b/Scripts/gitea/remotes.js new file mode 100644 index 0000000..4c489e0 --- /dev/null +++ b/Scripts/gitea/remotes.js @@ -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; diff --git a/Scripts/gitea/router.js b/Scripts/gitea/router.js new file mode 100644 index 0000000..c493f5c --- /dev/null +++ b/Scripts/gitea/router.js @@ -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; diff --git a/Scripts/main.js b/Scripts/main.js new file mode 100644 index 0000000..b002e6b --- /dev/null +++ b/Scripts/main.js @@ -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; +}; diff --git a/Scripts/store.js b/Scripts/store.js new file mode 100644 index 0000000..03703b3 --- /dev/null +++ b/Scripts/store.js @@ -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; diff --git a/Scripts/ui.js b/Scripts/ui.js new file mode 100644 index 0000000..28a8899 --- /dev/null +++ b/Scripts/ui.js @@ -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; diff --git a/Scripts/util/config.js b/Scripts/util/config.js new file mode 100644 index 0000000..ac23e6d --- /dev/null +++ b/Scripts/util/config.js @@ -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/"; diff --git a/Scripts/util/exec.js b/Scripts/util/exec.js new file mode 100644 index 0000000..64d444d --- /dev/null +++ b/Scripts/util/exec.js @@ -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; diff --git a/Scripts/util/git.js b/Scripts/util/git.js new file mode 100644 index 0000000..01fe109 --- /dev/null +++ b/Scripts/util/git.js @@ -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; diff --git a/Scripts/util/log.js b/Scripts/util/log.js new file mode 100644 index 0000000..15399c4 --- /dev/null +++ b/Scripts/util/log.js @@ -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); +}; diff --git a/Scripts/util/time.js b/Scripts/util/time.js new file mode 100644 index 0000000..1cca40d --- /dev/null +++ b/Scripts/util/time.js @@ -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; diff --git a/Scripts/views/branchView.js b/Scripts/views/branchView.js new file mode 100644 index 0000000..2f5e2b9 --- /dev/null +++ b/Scripts/views/branchView.js @@ -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; diff --git a/Scripts/views/builders.js b/Scripts/views/builders.js new file mode 100644 index 0000000..e7af75d --- /dev/null +++ b/Scripts/views/builders.js @@ -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; diff --git a/Scripts/views/nodes.js b/Scripts/views/nodes.js new file mode 100644 index 0000000..d35469b --- /dev/null +++ b/Scripts/views/nodes.js @@ -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; diff --git a/Scripts/views/provider.js b/Scripts/views/provider.js new file mode 100644 index 0000000..aa8017b --- /dev/null +++ b/Scripts/views/provider.js @@ -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; diff --git a/Scripts/views/pullRequestsView.js b/Scripts/views/pullRequestsView.js new file mode 100644 index 0000000..fded5fa --- /dev/null +++ b/Scripts/views/pullRequestsView.js @@ -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; diff --git a/Scripts/views/settingsView.js b/Scripts/views/settingsView.js new file mode 100644 index 0000000..014a21a --- /dev/null +++ b/Scripts/views/settingsView.js @@ -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; diff --git a/Scripts/views/workflowsView.js b/Scripts/views/workflowsView.js new file mode 100644 index 0000000..6dab97b --- /dev/null +++ b/Scripts/views/workflowsView.js @@ -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; diff --git a/Tests/README.md b/Tests/README.md new file mode 100644 index 0000000..1f8479a --- /dev/null +++ b/Tests/README.md @@ -0,0 +1,37 @@ +# Tests + +Two suites that run the extension's real code under plain Node. Nova's runtime +and a Gitea server are both stubbed, so there is nothing to install and nothing +to reach over the network. + +```sh +Tests/run.sh # both suites +node Tests/units.test.js +node Tests/integration.test.js +``` + +Both exit non-zero on failure, so `Tests/run.sh` works as a CI step. + +## What is here + +| File | Purpose | +| --- | --- | +| `nova-stub.js` | Stands in for Nova's runtime: `nova.*`, `TreeView`, `TreeItem`, `Issue`, `IssueCollection`, `Process`, `Range`, `NotificationRequest`. Records messages, clipboard writes, and opened files on globals so assertions can inspect them. | +| `fake-gitea.js` | A canned Gitea 1.27 instance served through a `fetch()` stub — three workflow runs, two pull requests, reviews with a line comment, artifacts, secrets, and variables. Records every request it answers. | +| `assert.js` | The `Checker` used by both suites. | +| `units.test.js` | Remote URL parsing, diff-hunk arithmetic, per-file diff extraction, time formatting, preference precedence, HTTP error messages, the pre-1.24 `/actions/tasks` fallback, pagination and query building, and the no-token path. | +| `integration.test.js` | Activates the extension against a real throwaway git checkout, renders all four sidebar sections, and drives the commands that write files or call the API. | + +`integration.test.js` creates its git fixture in a temporary directory and +removes it afterwards; nothing is written inside the repository. + +## Adding a case + +Fixtures live in `fake-gitea.js` — add to the exported data and, if the route is +new, to the router at the bottom of that file. Assertions use +`t.check(label, condition, detail)` or `t.equals(label, actual, expected)`; +`detail` is printed only when the check fails. + +If a check fails on a value you believe is correct, confirm the expectation by +hand before changing the extension — the diff-hunk line arithmetic in +particular is easy to get wrong in a test. diff --git a/Tests/assert.js b/Tests/assert.js new file mode 100644 index 0000000..00d69c3 --- /dev/null +++ b/Tests/assert.js @@ -0,0 +1,46 @@ +// +// Tiny assertion helpers shared by the suites. Each suite owns a Checker; the +// process exit code comes from its failure count. +// + +class Checker { + constructor() { + this.failures = 0; + } + + /** Records a pass or failure. `detail` is printed only when it fails. */ + check(label, condition, detail) { + if (condition) { + console.log(` ok ${label}`); + return true; + } + this.failures += 1; + console.log(` FAIL ${label}${detail !== undefined ? " — " + detail : ""}`); + return false; + } + + /** Deep-ish equality by JSON shape, which is enough for these fixtures. */ + equals(label, actual, expected) { + return this.check( + label, + JSON.stringify(actual) === JSON.stringify(expected), + `got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}`, + ); + } + + section(title) { + console.log(`\n=== ${title} ===`); + } + + /** Prints the tally and exits non-zero when anything failed. */ + finish(name) { + if (this.failures === 0) { + console.log(`\nALL ${name} PASSED`); + process.exit(0); + } + console.log(`\n${this.failures} ${name} FAILED`); + process.exit(1); + } +} + +module.exports = { Checker }; diff --git a/Tests/fake-gitea.js b/Tests/fake-gitea.js new file mode 100644 index 0000000..81038b5 --- /dev/null +++ b/Tests/fake-gitea.js @@ -0,0 +1,206 @@ +// A canned Gitea instance served through a fetch() stub. +const BASE = "https://gitea.test/api/v1"; + +const now = new Date(); +const iso = (minutesAgo) => new Date(now.getTime() - minutesAgo * 60000).toISOString(); + +const RUNS = [ + { id: 101, run_number: 12, display_title: "Fix the flaky test", path: ".gitea/workflows/ci.yml", + event: "push", status: "completed", conclusion: "success", head_branch: "feature/login", + head_sha: "abc1234def", created_at: iso(30), started_at: iso(29), completed_at: iso(26), + html_url: "https://gitea.test/acme/widget/actions/runs/101", actor: { login: "alice" } }, + { id: 102, run_number: 13, display_title: "Add login form", path: ".gitea/workflows/ci.yml", + event: "pull_request", status: "running", conclusion: "", head_branch: "feature/login", + head_sha: "def5678abc", created_at: iso(4), started_at: iso(4), completed_at: null, + html_url: "https://gitea.test/acme/widget/actions/runs/102", actor: { login: "bob" } }, + { id: 103, run_number: 5, display_title: "Nightly", path: ".gitea/workflows/nightly.yml", + event: "schedule", status: "completed", conclusion: "failure", head_branch: "main", + head_sha: "999aaa111", created_at: iso(300), started_at: iso(300), completed_at: iso(295), + html_url: "https://gitea.test/acme/widget/actions/runs/103", actor: { login: "ci" } }, +]; + +const JOBS = { + 101: [{ id: 9001, name: "build", status: "completed", conclusion: "success", + started_at: iso(29), completed_at: iso(27), runner_name: "runner-1", + html_url: "https://gitea.test/acme/widget/actions/runs/101/jobs/0", + steps: [{ number: 1, name: "Checkout", status: "completed", conclusion: "success", started_at: iso(29), completed_at: iso(29) }, + { number: 2, name: "Test", status: "completed", conclusion: "success", started_at: iso(29), completed_at: iso(27) }] }], + 102: [{ id: 9002, name: "build", status: "running", conclusion: "", started_at: iso(4), + completed_at: null, steps: [] }], + 103: [{ id: 9003, name: "nightly", status: "completed", conclusion: "failure", + started_at: iso(300), completed_at: iso(295), steps: [] }], +}; + +const ARTIFACTS = { + 101: [{ id: 555, name: "coverage", size_in_bytes: 20480, expired: false, + archive_download_url: BASE + "/repos/acme/widget/actions/artifacts/555/zip" }], +}; + +const DIFF_HUNK = [ + "@@ -10,4 +10,7 @@ function login() {", + " const form = document.querySelector('form');", + "- form.submit();", + "+ if (!validate(form)) {", + "+ return;", + "+ }", + "+ form.submit();", +].join("\n"); + +const PULLS = [ + { number: 7, title: "Add login form", state: "open", draft: false, merged: false, mergeable: true, + user: { login: "bob" }, updated_at: iso(3), created_at: iso(120), + head: { ref: "feature/login", label: "acme:feature/login", sha: "def5678abc" }, + base: { ref: "main", label: "acme:main" }, + body: "Adds a login form and validates it before submit.", + html_url: "https://gitea.test/acme/widget/pulls/7" }, + { number: 6, title: "Bump deps", state: "open", draft: true, merged: false, mergeable: true, + user: { login: "alice" }, updated_at: iso(600), created_at: iso(900), + head: { ref: "chore/deps", label: "acme:chore/deps", sha: "111222333" }, + base: { ref: "main", label: "acme:main" }, body: "", + html_url: "https://gitea.test/acme/widget/pulls/6" }, +]; + +const REVIEWS = { + 7: [{ id: 4001, state: "REQUEST_CHANGES", body: "Please guard the submit.", comments_count: 1, + user: { login: "alice" }, submitted_at: iso(10), + html_url: "https://gitea.test/acme/widget/pulls/7#pullrequestreview-4001" }, + { id: 4002, state: "APPROVED", body: "LGTM now", comments_count: 0, + user: { login: "carol" }, submitted_at: iso(2) }], + 6: [], +}; + +const REVIEW_COMMENTS = { + 4001: [{ id: 7001, body: "Guard this before submitting.", path: "src/login.js", position: 13, + pull_request_review_id: 4001, user: { login: "alice" }, created_at: iso(10), + diff_hunk: DIFF_HUNK, resolver: null, + html_url: "https://gitea.test/acme/widget/pulls/7#issuecomment-7001" }], +}; + +const FILES = { + 7: [{ filename: "src/login.js", status: "modified", additions: 4, deletions: 1, + html_url: "https://gitea.test/acme/widget/pulls/7/files#src-login-js" }, + { filename: "src/new.js", status: "added", additions: 12, deletions: 0 }], + 6: [{ filename: "package.json", status: "modified", additions: 2, deletions: 2 }], +}; + +const PR_DIFF = [ + "diff --git a/src/login.js b/src/login.js", + "index 1111111..2222222 100644", + "--- a/src/login.js", + "+++ b/src/login.js", + DIFF_HUNK, + "diff --git a/src/new.js b/src/new.js", + "new file mode 100644", + "index 0000000..3333333", + "--- /dev/null", + "+++ b/src/new.js", + "@@ -0,0 +1,2 @@", + "+export function noop() {}", + "", +].join("\n"); + +const SECRETS = [{ name: "DEPLOY_KEY", created_at: iso(5000), description: "" }]; +const VARIABLES = [{ name: "REGISTRY", data: "registry.example.com" }]; + +const calls = []; + +function json(body, status = 200) { + const payload = JSON.stringify(body); + return { + ok: status < 400, status, + text: async () => payload, + json: async () => body, + arrayBuffer: async () => new TextEncoder().encode(payload).buffer, + }; +} + +function plain(body, status = 200) { + return { + ok: status < 400, status, + text: async () => body, + json: async () => JSON.parse(body), + arrayBuffer: async () => new TextEncoder().encode(body).buffer, + }; +} + +global.fetch = async (url, init) => { + const method = (init && init.method) || "GET"; + const parsed = new URL(url); + const p = parsed.pathname.replace("/api/v1", ""); + const q = parsed.searchParams; + calls.push(method + " " + p + (q.toString() ? "?" + q.toString() : "")); + + const auth = init && init.headers && init.headers["Authorization"]; + if (!auth) return json({ message: "token required" }, 401); + + const page = Number(q.get("page") || 1); + const first = (list) => (page > 1 ? [] : list); + + if (p === "/version") return json({ version: "1.27.2" }); + if (p === "/user") return json({ login: "tester", id: 1 }); + if (p === "/user/repos") return json(first([ + { name: "widget", full_name: "acme/widget", owner: { login: "acme" }, + html_url: "https://gitea.test/acme/widget", default_branch: "main" }])); + + const m = p.match(/^\/repos\/([^/]+)\/([^/]+)(\/.*)?$/); + if (!m) return json({ message: "no route for " + p }, 404); + const rest = m[3] || ""; + + if (rest === "") return json({ name: m[2], full_name: m[1] + "/" + m[2] }); + if (rest === "/branches") return json(first([{ name: "main" }, { name: "feature/login" }])); + if (rest === "/actions/runs") { + let runs = RUNS; + if (q.get("branch")) runs = runs.filter((r) => r.head_branch === q.get("branch")); + return json({ total_count: runs.length, workflow_runs: runs }); + } + if (rest === "/actions/workflows") { + return json({ workflows: [{ id: 1, name: "CI", path: ".gitea/workflows/ci.yml" }] }); + } + + let sub = rest.match(/^\/actions\/runs\/(\d+)\/jobs$/); + if (sub) return json({ jobs: JOBS[sub[1]] || [] }); + sub = rest.match(/^\/actions\/runs\/(\d+)\/artifacts$/); + if (sub) return json({ artifacts: ARTIFACTS[sub[1]] || [] }); + sub = rest.match(/^\/actions\/jobs\/(\d+)\/logs$/); + if (sub) return plain("Starting job " + sub[1] + "\nDone\n"); + sub = rest.match(/^\/actions\/artifacts\/(\d+)\/zip$/); + if (sub) return plain("PKfake-zip-bytes"); + if (/^\/actions\/runs\/\d+\/(rerun|rerun-failed-jobs|cancel)$/.test(rest)) return json(null, 204); + if (/^\/actions\/runs\/\d+\/jobs\/\d+\/rerun$/.test(rest)) return json(null, 204); + + if (rest === "/actions/secrets") return json(first(SECRETS)); + if (rest === "/actions/variables") return json(first(VARIABLES)); + if (/^\/actions\/secrets\/[^/]+$/.test(rest)) return json(null, 204); + if (/^\/actions\/variables\/[^/]+$/.test(rest)) return json(null, 204); + + if (rest === "/pulls" && method === "GET") return json(first(PULLS)); + if (rest === "/pulls" && method === "POST") { + return json({ number: 8, html_url: "https://gitea.test/acme/widget/pulls/8" }); + } + + sub = rest.match(/^\/pulls\/(\d+)$/); + if (sub) return json(PULLS.find((x) => x.number === Number(sub[1])) || null); + sub = rest.match(/^\/pulls\/(\d+)\.diff$/); + if (sub) return plain(PR_DIFF); + sub = rest.match(/^\/pulls\/(\d+)\/files$/); + if (sub) return json(first(FILES[sub[1]] || [])); + sub = rest.match(/^\/pulls\/(\d+)\/reviews$/); + if (sub && method === "GET") return json(first(REVIEWS[sub[1]] || [])); + if (sub && method === "POST") return json({ id: 4003 }); + sub = rest.match(/^\/pulls\/(\d+)\/reviews\/(\d+)\/comments$/); + if (sub) return json(REVIEW_COMMENTS[sub[2]] || []); + if (/^\/pulls\/\d+\/merge$/.test(rest)) return json(null, 204); + if (/^\/pulls\/\d+\/comments\/\d+\/replies$/.test(rest)) return json({ id: 7002 }); + if (/^\/pulls\/comments\/\d+\/(un)?resolve$/.test(rest)) return json(null, 204); + + sub = rest.match(/^\/issues\/(\d+)$/); + if (sub) return json({ number: Number(sub[1]), state: "closed" }); + if (/^\/issues\/\d+\/timeline$/.test(rest)) { + return json(first([{ type: "comment", body: "Looks good", user: { login: "carol" }, created_at: iso(5) }])); + } + if (/^\/issues\/\d+\/comments$/.test(rest)) return json(first([])); + + return json({ message: "no route for " + p }, 404); +}; + +module.exports = { calls, RUNS, PULLS, DIFF_HUNK, PR_DIFF }; diff --git a/Tests/integration.test.js b/Tests/integration.test.js new file mode 100644 index 0000000..24207e4 --- /dev/null +++ b/Tests/integration.test.js @@ -0,0 +1,255 @@ +// +// End-to-end: activates the real extension against a real git checkout and a +// canned Gitea instance, then renders every sidebar section and drives the +// commands that touch the filesystem or the API. +// + +const { EXTENSION_ROOT } = require("./nova-stub.js"); +const fake = require("./fake-gitea.js"); +const { Checker } = require("./assert.js"); + +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); +const { execFileSync } = require("node:child_process"); + +const script = (...parts) => path.join(EXTENSION_ROOT, "Scripts", ...parts); +const t = new Checker(); + +// -- fixture --------------------------------------------------------------- + +const WORKSPACE = fs.mkdtempSync(path.join(os.tmpdir(), "gitea-nova-test-")); +const REPO = path.join(WORKSPACE, "widget"); + +function buildRepository() { + fs.mkdirSync(path.join(REPO, "src"), { recursive: true }); + fs.writeFileSync( + path.join(REPO, "src", "login.js"), + Array.from({ length: 20 }, (_, i) => `// line ${i + 1}`).join("\n") + "\n", + ); + fs.writeFileSync(path.join(REPO, "README.md"), "# widget\n"); + + const git = (...args) => execFileSync("git", args, { cwd: REPO, encoding: "utf-8" }); + git("init", "-q", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + git("remote", "add", "origin", "git@gitea.test:acme/widget.git"); + git("add", "-A"); + git("commit", "-qm", "initial"); + git("checkout", "-qb", "feature/login"); +} + +buildRepository(); + +nova.workspace.path = WORKSPACE; +nova.config.set("gitea.baseUrl", "https://gitea.test"); +nova.credentials.setPassword("unsupervised.giteanova", "https://gitea.test", "tok_test"); + +// -- helpers --------------------------------------------------------------- + +/** Renders a provider's tree to indented text, expanding lazy rows as it goes. */ +async function render(provider, node = null, depth = 0, lines = []) { + for (const child of await provider.getChildren(node)) { + const item = provider.getTreeItem(child); + const description = item.descriptiveText ? ` [${item.descriptiveText}]` : ""; + lines.push(`${" ".repeat(depth)}${item.name}${description}`); + if (depth < 3 && (child.children || child.load)) { + await render(provider, child, depth + 1, lines); + } + } + return lines; +} + +/** Every command name the manifest exposes to the user. */ +function declaredCommands(manifest) { + const declared = new Set(); + + for (const list of Object.values(manifest.commands || {})) { + for (const entry of list) if (entry.command) declared.add(entry.command); + } + for (const sidebar of manifest.sidebars || []) { + for (const section of sidebar.sections || []) { + const entries = [...(section.headerCommands || []), ...(section.contextCommands || [])]; + for (const entry of entries) if (entry.command) declared.add(entry.command); + } + } + const walk = (entry) => { + if (entry.command) declared.add(entry.command); + for (const child of entry.children || []) walk(child); + }; + for (const entry of manifest.config || []) walk(entry); + + return declared; +} + +// -- suite ----------------------------------------------------------------- + +(async () => { + const main = require(script("main.js")); + main.activate(); + await new Promise((resolve) => setTimeout(resolve, 400)); // let activation settle + + const store = require(script("controllers", "selection.js")).store(); + const views = Object.fromEntries(global.__treeViews.map((view) => [view.id, view])); + + t.section("discovery"); + console.log( + store.repos + .map((r) => `${r.fullName} @ ${r.baseUrl} branch=${r.branch}`) + .join("\n") || "(none)", + ); + t.check("one repository discovered", store.repos.length === 1, `got ${store.repos.length}`); + t.check("routed to the matching instance", store.repos[0]?.baseUrl === "https://gitea.test"); + t.check("branch read from git", store.repos[0]?.branch === "feature/login", store.repos[0]?.branch); + t.check( + "instance reported healthy", + store.instanceStatus.get("https://gitea.test")?.ok === true, + JSON.stringify(store.instanceStatus.get("https://gitea.test")), + ); + + for (const id of ["gitea.branch", "gitea.workflows", "gitea.pullRequests", "gitea.settings"]) { + t.section(id); + const lines = await render(views[id].dataProvider); + console.log(lines.join("\n")); + t.check(`${id} renders rows`, lines.length > 0); + t.check( + `${id} renders no error rows`, + !lines.some((line) => line.includes("⚠")), + lines.filter((line) => line.includes("⚠")).join(" | "), + ); + } + + t.section("branch filtering"); + const branchLines = await render(views["gitea.branch"].dataProvider); + t.check("shows the branch's pull request", branchLines.some((l) => l.includes("#7 Add login form"))); + t.check("hides another branch's pull request", !branchLines.some((l) => l.includes("#6 Bump deps"))); + t.check("hides another branch's run", !branchLines.some((l) => l.includes("Nightly"))); + + t.section("workflow grouping"); + const workflowLines = await render(views["gitea.workflows"].dataProvider); + t.check( + "grouped by workflow file", + workflowLines.some((l) => l.trim().startsWith("ci.yml")) && + workflowLines.some((l) => l.trim().startsWith("nightly.yml")), + ); + t.check("jobs load under a run", workflowLines.some((l) => l.includes("build"))); + t.check("steps load under a job", workflowLines.some((l) => l.includes("Checkout"))); + t.check("artifacts listed under a run", workflowLines.some((l) => l.includes("coverage"))); + + t.section("inline review comments"); + const { ReviewCommentsController } = require(script("controllers", "reviewComments.js")); + const comments = new ReviewCommentsController(store); + await comments.reload({ force: true }); + + const annotated = [...comments.collection.map.entries()]; + for (const [uri, issues] of annotated) { + console.log(uri); + for (const issue of issues) console.log(` line ${issue.line} [${issue.source}] ${issue.message}`); + } + t.check("exactly one file annotated", annotated.length === 1, `got ${annotated.length}`); + t.check("annotation lands on the commented file", annotated[0]?.[0].endsWith("/src/login.js")); + // The hunk adds three lines after one context line starting at new line 10, + // so the commented line — the hunk's last — is line 14. + t.check("line derived from the diff hunk", annotated[0]?.[1][0].line === 14, annotated[0]?.[1][0].line); + t.check("request-changes comment raised as an error", annotated[0]?.[1][0].severity === IssueSeverity.Error); + comments.dispose(); + + t.section("manifest and command registration"); + const manifest = JSON.parse(fs.readFileSync(path.join(EXTENSION_ROOT, "extension.json"), "utf-8")); + const declared = declaredCommands(manifest); + const registered = global.__commands; + + const missing = [...declared].filter((name) => !registered.has(name)); + t.check("every command the manifest offers is registered", missing.length === 0, missing.join(", ")); + + // gitea.openReviewComment is deliberately absent: it is a TreeItem.command + // for double-click, never a menu entry. + const extra = [...registered.keys()].filter((name) => !declared.has(name)); + t.equals("only the double-click handler is manifest-free", extra, ["gitea.openReviewComment"]); + + t.section("command behaviour"); + const branch = views["gitea.branch"]; + const roots = await branch.dataProvider.getChildren(null); + const pullFolder = roots.find((n) => n.kind === "prFolder"); + const pullNode = (await branch.dataProvider.getChildren(pullFolder)).find((n) => n.kind === "pr"); + branch.selection = [pullNode]; + + await registered.get("gitea.copyUrl")(branch); + t.check( + "copy URL uses the pull request's own page", + global.__clipboard === "https://gitea.test/acme/widget/pulls/7", + global.__clipboard, + ); + + await registered.get("gitea.openPullRequestDiff")(branch); + t.check("diff saved into the repository", fs.existsSync(path.join(REPO, ".tmp", "gitea-diffs", "pr-7.diff"))); + + await registered.get("gitea.openPullRequestOverview")(branch); + const overview = global.__opened?.untitled?.content ?? ""; + t.check("overview headed with the pull request title", overview.includes("# #7 Add login form")); + t.check("overview lists reviews", overview.includes("alice") && overview.includes("request_changes")); + t.check("overview lists changed files", overview.includes("src/login.js")); + + const runsFolder = roots.find((n) => n.kind === "runsFolder"); + const runNode = (await branch.dataProvider.getChildren(runsFolder)).find((n) => n.kind === "run"); + const jobNode = (await branch.dataProvider.getChildren(runNode)).find((n) => n.kind === "job"); + branch.selection = [jobNode]; + await registered.get("gitea.viewJobLogs")(branch); + + const logsDir = path.join(REPO, ".tmp", "gitea-logs"); + const logFiles = fs.existsSync(logsDir) ? fs.readdirSync(logsDir) : []; + t.check("job log written into the repository", logFiles.length === 1, logFiles.join(", ")); + + const workflows = views["gitea.workflows"]; + const ciGroup = (await workflows.dataProvider.getChildren(null)).find( + (n) => n.kind === "workflow" && n.name === "ci.yml", + ); + const finishedRun = (await workflows.dataProvider.getChildren(ciGroup)).find( + (n) => n.data.run.id === 101, + ); + const artifactsFolder = (await workflows.dataProvider.getChildren(finishedRun)).find( + (n) => n.kind === "artifactsFolder", + ); + workflows.selection = [(await workflows.dataProvider.getChildren(artifactsFolder))[0]]; + await registered.get("gitea.downloadArtifact")(workflows); + + const zip = path.join(REPO, ".tmp", "gitea-artifacts", "acme", "widget", "101", "coverage.zip"); + t.check("artifact downloaded to the configured path", fs.existsSync(zip), zip); + + t.section("secrets and variables"); + const settingsLines = await render(views["gitea.settings"].dataProvider); + console.log(settingsLines.join("\n")); + t.check("secret listed", settingsLines.some((l) => l.includes("DEPLOY_KEY"))); + t.check("variable listed", settingsLines.some((l) => l.includes("REGISTRY"))); + + t.section("write operations"); + global.__messages.length = 0; + workflows.selection = [finishedRun]; + await registered.get("gitea.rerunRun")(workflows); + t.check( + "re-run reported as requested", + global.__messages.some(([kind, message]) => kind === "info" && message.includes("Re-run")), + JSON.stringify(global.__messages), + ); + + global.__messages.length = 0; + global.__inputAnswer = "Looks good to me"; + branch.selection = [pullNode]; + await registered.get("gitea.reviewApprove")(branch); + t.check( + "approval submitted", + global.__messages.some(([kind, message]) => kind === "info" && message.includes("Review submitted")), + JSON.stringify(global.__messages), + ); + + t.section("api paths exercised"); + console.log([...new Set(fake.calls.map((call) => call.split("?")[0]))].sort().join("\n")); + + main.deactivate(); + fs.rmSync(WORKSPACE, { recursive: true, force: true }); + t.finish("INTEGRATION CHECKS"); +})().catch((error) => { + console.error("HARNESS ERROR", error); + fs.rmSync(WORKSPACE, { recursive: true, force: true }); + process.exit(2); +}); diff --git a/Tests/nova-stub.js b/Tests/nova-stub.js new file mode 100644 index 0000000..62872e6 --- /dev/null +++ b/Tests/nova-stub.js @@ -0,0 +1,158 @@ +// Minimal stand-in for Nova's extension runtime, enough to exercise the +// extension's logic under plain Node. +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); + +const EXTENSION_ROOT = path.resolve(__dirname, ".."); + +const configStore = new Map(); +const workspaceStore = new Map(); + +function makeConfig(store) { + return { + get(key) { return store.has(key) ? store.get(key) : null; }, + set(key, value) { store.set(key, value); }, + remove(key) { store.delete(key); }, + onDidChange() { return { dispose() {} }; }, + observe() { return { dispose() {} }; }, + }; +} + +global.TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; +global.TreeItem = class TreeItem { + constructor(name, state) { this.name = name; this.collapsibleState = state; } +}; +global.IssueSeverity = { Error: 0, Warning: 1, Hint: 2, Info: 3 }; +global.Issue = class Issue {}; +global.IssueCollection = class IssueCollection { + constructor(name) { this.name = name; this.map = new Map(); } + set(uri, issues) { this.map.set(uri, issues); } + get(uri) { return this.map.get(uri); } + has(uri) { return this.map.has(uri); } + remove(uri) { this.map.delete(uri); } + clear() { this.map.clear(); } + dispose() { this.map.clear(); } +}; +global.Range = class Range { + constructor(start, end) { this.start = start; this.end = end; } +}; +global.NotificationRequest = class NotificationRequest { + constructor(id) { this.identifier = id; } +}; +global.Process = class Process { + constructor(command, options) { this.command = command; this.options = options; } + onStdout(cb) { this._out = cb; } + onStderr(cb) { this._err = cb; } + onDidExit(cb) { this._exit = cb; } + start() { + const { spawnSync } = require("node:child_process"); + const args = this.options.args || []; + const result = spawnSync(this.command, args, { cwd: this.options.cwd, encoding: "utf-8" }); + if (result.stdout && this._out) this._out(result.stdout); + if (result.stderr && this._err) this._err(result.stderr); + setImmediate(() => this._exit(result.status === null ? -1 : result.status)); + } +}; + +const commands = new Map(); +const treeViews = []; + +global.TreeView = class TreeView { + constructor(id, options) { + this.id = id; + this.dataProvider = options.dataProvider; + this.visible = true; + this.selection = []; + treeViews.push(this); + } + reload() { return Promise.resolve(); } + dispose() {} +}; + +const keychain = new Map(); + +global.nova = { + version: [11, 0, 0], + versionString: "11.0", + inDevMode: () => true, + extension: { path: EXTENSION_ROOT }, + config: makeConfig(configStore), + clipboard: { writeText(text) { global.__clipboard = text; } }, + credentials: { + getPassword(service, user) { return keychain.get(`${service}:${user}`) ?? null; }, + setPassword(service, user, password) { keychain.set(`${service}:${user}`, password); }, + removePassword(service, user) { keychain.delete(`${service}:${user}`); }, + }, + commands: { + register(name, handler) { + if (commands.has(name)) throw new Error(`duplicate command: ${name}`); + commands.set(name, handler); + return { dispose() {} }; + }, + invoke(name, ...args) { + const handler = commands.get(name); + if (!handler) return Promise.reject(new Error(`no such command: ${name}`)); + return Promise.resolve(handler(...args)); + }, + }, + subscriptions: { add() {}, remove() {} }, + openConfig() {}, openURL(url) { global.__openedUrl = url; }, beep() {}, + path: { + join: (...parts) => path.join(...parts), + dirname: (p) => path.dirname(p), + basename: (p) => path.basename(p), + normalize: (p) => path.normalize(p), + isAbsolute: (p) => path.isAbsolute(p), + expanduser: (p) => p.replace(/^~/, os.homedir()), + split: (p) => p.split("/").filter(Boolean), + splitext: (p) => [p.slice(0, p.lastIndexOf(".")), p.slice(p.lastIndexOf("."))], + }, + fs: { + F_OK: 0, R_OK: 4, W_OK: 2, X_OK: 1, + access(p) { return fs.existsSync(p); }, + stat(p) { + if (!fs.existsSync(p)) return null; + const s = fs.statSync(p); + return { size: s.size, isFile: () => s.isFile(), isDirectory: () => s.isDirectory() }; + }, + listdir(p) { return fs.readdirSync(p); }, + mkdir(p) { fs.mkdirSync(p); }, + remove(p) { fs.unlinkSync(p); }, + reveal(p) { global.__revealed = p; }, + open(p, mode) { + const binary = mode.includes("b"); + const chunks = []; + return { + write(data) { chunks.push(binary ? Buffer.from(data) : Buffer.from(String(data), "utf-8")); }, + close() { fs.writeFileSync(p, Buffer.concat(chunks)); }, + }; + }, + }, + workspace: { + path: null, + config: makeConfig(workspaceStore), + activeTextEditor: null, + textEditors: [], + openFile(p, options) { global.__opened = { path: p, options }; return Promise.resolve({}); }, + openNewTextDocument(options) { global.__opened = { untitled: options }; return Promise.resolve({}); }, + showInformativeMessage(m) { global.__messages.push(["info", m]); }, + showWarningMessage(m) { global.__messages.push(["warn", m]); }, + showErrorMessage(m) { global.__messages.push(["error", m]); }, + showActionPanel(m, o, cb) { global.__messages.push(["action", m]); cb(global.__actionAnswer ?? null); }, + showInputPanel(m, o, cb) { global.__messages.push(["input", m]); cb(global.__inputAnswer ?? null); }, + showInputPalette(m, o, cb) { cb(global.__inputAnswer ?? null); }, + showChoicePalette(items, o, cb) { + const i = global.__choiceIndex ?? 0; + cb(items[i] ?? null, items[i] === undefined ? null : i); + }, + }, + notifications: { add() { return Promise.resolve({ actionIdx: 2 }); } }, +}; + +global.__messages = []; +global.__commands = commands; +global.__treeViews = treeViews; +global.__keychain = keychain; + +module.exports = { EXTENSION_ROOT }; diff --git a/Tests/run.sh b/Tests/run.sh new file mode 100755 index 0000000..bea1b52 --- /dev/null +++ b/Tests/run.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# +# Runs both suites. Needs only node and git on PATH — no npm install, no +# network: the Gitea instance is a fetch() stub and Nova's runtime is stubbed. +# +set -e + +cd "$(dirname "$0")" + +status=0 +for suite in units.test.js integration.test.js; do + printf '\n########## %s ##########\n' "$suite" + node "$suite" || status=1 +done + +printf '\n' +if [ "$status" -eq 0 ]; then + echo "All suites passed." +else + echo "One or more suites failed." +fi +exit "$status" diff --git a/Tests/units.test.js b/Tests/units.test.js new file mode 100644 index 0000000..4956da6 --- /dev/null +++ b/Tests/units.test.js @@ -0,0 +1,256 @@ +// +// Units and edge cases: remote URL parsing, diff arithmetic, time formatting, +// preference precedence, HTTP error surfacing, pagination, and the unconfigured +// and legacy-server paths. +// + +const { EXTENSION_ROOT } = require("./nova-stub.js"); +const { PR_DIFF } = require("./fake-gitea.js"); +const { Checker } = require("./assert.js"); + +const path = require("node:path"); + +const script = (...parts) => path.join(EXTENSION_ROOT, "Scripts", ...parts); +const t = new Checker(); + +/** Swaps in a fetch implementation for the duration of `body`. */ +async function withResponder(responder, body) { + const saved = global.fetch; + global.fetch = responder; + try { + return await body(); + } finally { + global.fetch = saved; + } +} + +const ok = (payload) => ({ ok: true, status: 200, text: async () => JSON.stringify(payload) }); +const fail = (status, message) => ({ + ok: false, + status, + text: async () => JSON.stringify({ message }), +}); + +(async () => { + t.section("remote URL parsing"); + const remotes = require(script("gitea", "remotes.js")); + const parsed = (host, owner, name, port = null) => ({ host, port, owner, name }); + + t.equals("https", remotes.parse("https://gitea.com/acme/widget.git"), parsed("gitea.com", "acme", "widget")); + t.equals("https without .git", remotes.parse("https://gitea.com/acme/widget"), parsed("gitea.com", "acme", "widget")); + t.equals( + "https with credentials", + remotes.parse("https://user:pass@git.example.com/acme/widget.git"), + parsed("git.example.com", "acme", "widget"), + ); + t.equals( + "https with port", + remotes.parse("https://git.example.com:3000/acme/widget.git"), + parsed("git.example.com", "acme", "widget", 3000), + ); + t.equals("scp-style ssh", remotes.parse("git@gitea.com:acme/widget.git"), parsed("gitea.com", "acme", "widget")); + t.equals( + "ssh:// with port", + remotes.parse("ssh://git@git.example.com:2222/acme/widget.git"), + parsed("git.example.com", "acme", "widget", 2222), + ); + t.equals( + "instance served from a sub-path", + remotes.parse("https://example.com/gitea/acme/widget.git"), + parsed("example.com", "acme", "widget"), + ); + t.equals("host lower-cased", remotes.parse("git@GITEA.COM:Acme/Widget.git").host, "gitea.com"); + t.equals("garbage rejected", remotes.parse("not a url"), null); + t.equals("empty rejected", remotes.parse(""), null); + t.equals("hostOf drops the port", remotes.hostOf("https://git.example.com:3000"), "git.example.com"); + t.equals("hostOf on a plain URL", remotes.hostOf("https://gitea.com"), "gitea.com"); + + t.section("diff hunk to file line"); + const review = require(script("controllers", "review.js")); + t.equals("additions only", review.lineFromDiffHunk("@@ -0,0 +1,2 @@\n+first\n+second", null), 2); + t.equals("removed lines do not advance the new file", review.lineFromDiffHunk("@@ -10,4 +10,7 @@\n ctx\n-gone\n+a\n+b", null), 12); + t.equals("single-line hunk header", review.lineFromDiffHunk("@@ -5 +5 @@\n+changed", null), 5); + t.equals("no hunk falls back to position", review.lineFromDiffHunk(null, 42), 42); + t.equals("unparseable hunk falls back", review.lineFromDiffHunk("no header here", 7), 7); + + t.section("per-file diff extraction"); + const pulls = require(script("controllers", "pullRequests.js")); + const first = pulls.extractFileDiff(PR_DIFF, "src/login.js"); + t.check( + "slices out only the first file", + first.startsWith("diff --git a/src/login.js") && !first.includes("src/new.js"), + JSON.stringify(first?.slice(0, 60)), + ); + const last = pulls.extractFileDiff(PR_DIFF, "src/new.js"); + t.check("slices out the last file", last.startsWith("diff --git a/src/new.js") && last.includes("noop")); + t.equals("unknown file yields null", pulls.extractFileDiff(PR_DIFF, "nope.js"), null); + + t.section("time formatting"); + const time = require(script("util", "time.js")); + t.equals("Go zero time renders blank", time.relative("0001-01-01T00:00:00Z"), ""); + t.equals("null renders blank", time.relative(null), ""); + t.check("a past time reads as ago", time.relative(new Date(Date.now() - 7200e3).toISOString()).endsWith("ago")); + t.equals("minutes and seconds", time.duration("2026-08-28T10:00:00Z", "2026-08-28T10:01:23Z"), "1m 23s"); + t.equals("hours and minutes", time.duration("2026-08-28T10:00:00Z", "2026-08-28T12:30:00Z"), "2h 30m"); + t.check( + "a zero end time still yields a duration", + typeof time.duration("2026-08-28T10:00:00Z", "0001-01-01T00:00:00Z") === "string", + ); + + t.section("preference precedence"); + const config = require(script("util", "config.js")); + + nova.config.set("gitea.baseUrl", "https://global.example.com"); + t.equals("global value used", config.get("gitea.baseUrl"), "https://global.example.com"); + nova.workspace.config.set("gitea.baseUrl", "https://workspace.example.com"); + t.equals("workspace overrides global", config.get("gitea.baseUrl"), "https://workspace.example.com"); + nova.workspace.config.remove("gitea.baseUrl"); + t.equals("falls back to global", config.get("gitea.baseUrl"), "https://global.example.com"); + nova.config.remove("gitea.baseUrl"); + t.equals("falls back to the built-in default", config.get("gitea.baseUrl"), "https://gitea.com"); + + t.equals("boolean default when unset", config.bool("gitea.reviewComments.enabled"), true); + nova.config.set("gitea.reviewComments.enabled", false); + t.equals("an explicit false is respected", config.bool("gitea.reviewComments.enabled"), false); + nova.config.remove("gitea.reviewComments.enabled"); + + t.equals("trailing slashes stripped", config.normalizeBaseUrl("https://gitea.com///"), "https://gitea.com"); + t.equals("missing scheme filled in", config.normalizeBaseUrl("gitea.example.com"), "https://gitea.example.com"); + t.equals("blank rejected", config.normalizeBaseUrl(" "), null); + + nova.config.set("gitea.baseUrl", "https://a.example.com"); + nova.config.set("gitea.instances", ["https://b.example.com/", "https://a.example.com", "https://b.example.com"]); + t.equals("instances de-duplicated, default first", config.instances(), [ + "https://a.example.com", + "https://b.example.com", + ]); + + t.section("HTTP error surfacing"); + const { GiteaClient, GiteaError } = require(script("gitea", "client.js")); + const { GiteaApi } = require(script("gitea", "api.js")); + const api = new GiteaApi(new GiteaClient("https://gitea.test", () => "tok")); + const repo = { owner: "acme", name: "widget" }; + + await withResponder(async () => fail(401, "token is invalid"), async () => { + try { + await api.currentUser(); + t.check("401 throws", false); + } catch (error) { + t.check("401 raises a GiteaError", error instanceof GiteaError); + t.check("401 flagged as an auth failure", error.isAuth === true); + t.check("401 tells the user what to do", error.message.includes("Set a valid token"), error.message); + } + }); + + await withResponder(async () => fail(403, "missing scope"), async () => { + try { + await api.listSecrets(repo); + t.check("403 throws", false); + } catch (error) { + t.check("403 names the cause", error.message.includes("scope"), error.message); + t.check("403 keeps the server's own detail", error.message.includes("missing scope"), error.message); + } + }); + + t.section("older Gitea servers"); + await withResponder( + async (url) => { + if (url.includes("/actions/runs")) return fail(404, "Not Found"); + if (url.includes("/actions/tasks")) { + return ok({ total_count: 1, workflow_runs: [{ id: 1, name: "legacy" }] }); + } + return fail(404, "Not Found"); + }, + async () => { + const result = await api.listRuns(repo, { limit: 10 }); + t.check( + "runs fall back to /actions/tasks", + result.legacy === true && result.runs.length === 1, + JSON.stringify(result), + ); + }, + ); + + await withResponder(async () => fail(404, "Not Found"), async () => { + t.equals("a missing artifacts endpoint yields an empty list", await api.listArtifacts(repo, 1), []); + }); + + t.section("query building and pagination"); + const paged = []; + await withResponder( + async (url) => { + paged.push(url); + const page = Number(new URL(url).searchParams.get("page")); + return ok( + page === 1 + ? Array.from({ length: 50 }, (_, i) => ({ number: i + 1 })) + : Array.from({ length: 5 }, (_, i) => ({ number: 51 + i })), + ); + }, + async () => { + t.equals("collects across pages", (await api.listPullRequests(repo, { limit: 60 })).length, 55); + t.check("page and limit sent", paged[0].includes("page=1") && paged[0].includes("limit=50"), paged[0]); + t.check( + "state and sort sent", + paged[0].includes("state=open") && paged[0].includes("sort=recentupdate"), + paged[0], + ); + t.check("stops after a short page", paged.length === 2, String(paged.length)); + }, + ); + + const built = []; + await withResponder( + async (url) => { + built.push(url); + return ok({ workflow_runs: [] }); + }, + async () => { + await api.listRuns(repo, { limit: 20, branch: null, event: null }); + t.check( + "null query parameters omitted entirely", + !built[0].includes("branch=") && !built[0].includes("event=") && built[0].includes("limit=20"), + built[0], + ); + }, + ); + + const escaped = []; + await withResponder( + async (url) => { + escaped.push(url); + return ok({}); + }, + async () => { + await api.repository({ owner: "my org", name: "a+b" }); + t.check("path segments percent-encoded", escaped[0].includes("/repos/my%20org/a%2Bb"), escaped[0]); + }, + ); + + t.section("nothing configured"); + const { Store } = require(script("store.js")); + nova.config.set("gitea.baseUrl", "https://untokened.example.com"); + nova.config.set("gitea.instances", []); + + const store = new Store(); + t.check("store reports no token", store.hasAnyToken === false); + await store.refresh({ rediscover: true }); + store.stop(); + + const status = store.instanceStatus.get("https://untokened.example.com"); + t.check("instance marked as needing a token", status?.needsToken === true, JSON.stringify(status)); + + const { BranchProvider } = require(script("views", "branchView.js")); + const rows = await new BranchProvider(store).getChildren(null); + t.check( + "the sidebar explains how to sign in", + rows.length === 1 && rows[0].name.includes("Set Token"), + rows.map((row) => row.name).join(" | "), + ); + store.dispose(); + + t.finish("UNIT CHECKS"); +})().catch((error) => { + console.error("HARNESS ERROR", error); + process.exit(2); +}); diff --git a/Tools/make-icons.py b/Tools/make-icons.py new file mode 100755 index 0000000..2afb270 --- /dev/null +++ b/Tools/make-icons.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +""" +Regenerates the extension's images. + +Nova wants each asset in Images//.png plus an @2x variant, and a +metadata.json marking it a template image so macOS tints it for light and dark +sidebars. The shapes are drawn as unit-square coverage tests and supersampled, +which keeps the whole thing dependency-free. + +Run from the extension root: python3 Tools/make-icons.py +""" + +import math +import os +import struct +import zlib + +SS = 4 # supersamples per axis + +def png(path, w, h, rgba): + raw = b"".join(b"\x00" + bytes(rgba[y*w*4:(y+1)*w*4]) for y in range(h)) + def chunk(tag, data): + c = tag + data + return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff) + out = (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"")) + os.makedirs(os.path.dirname(path), exist_ok=True) + open(path, "wb").write(out) + +def render(size, shape): + """shape(x, y) -> True when the unit-square point (0..1) is inked.""" + buf = bytearray(size * size * 4) + for py in range(size): + for px in range(size): + hits = 0 + for sy in range(SS): + for sx in range(SS): + x = (px + (sx + 0.5) / SS) / size + y = (py + (sy + 0.5) / SS) / size + if shape(x, y): + hits += 1 + a = int(round(255 * hits / (SS * SS))) + i = (py * size + px) * 4 + buf[i:i+4] = bytes((0, 0, 0, a)) + return buf + +# -- shape primitives, all in a 0..1 unit square --------------------------- + +def disc(cx, cy, r): + return lambda x, y: (x-cx)**2 + (y-cy)**2 <= r*r + +def ring(cx, cy, r, w): + return lambda x, y: abs(math.hypot(x-cx, y-cy) - r) <= w/2 + +def arc(cx, cy, r, w, a0, a1): + def f(x, y): + if abs(math.hypot(x-cx, y-cy) - r) > w/2: + return False + a = math.degrees(math.atan2(-(y-cy), x-cx)) % 360 + lo, hi = a0 % 360, a1 % 360 + return lo <= a <= hi if lo <= hi else (a >= lo or a <= hi) + return f + +def seg(x0, y0, x1, y1, w): + dx, dy = x1-x0, y1-y0 + L2 = dx*dx + dy*dy + def f(x, y): + t = 0.0 if L2 == 0 else max(0.0, min(1.0, ((x-x0)*dx + (y-y0)*dy) / L2)) + return math.hypot(x - (x0+t*dx), y - (y0+t*dy)) <= w/2 + return f + +def tri(p0, p1, p2): + def side(a, b, p): + return (b[0]-a[0])*(p[1]-a[1]) - (b[1]-a[1])*(p[0]-a[0]) + def f(x, y): + p = (x, y) + d0, d1, d2 = side(p0, p1, p), side(p1, p2, p), side(p2, p0, p) + return (d0 >= 0 and d1 >= 0 and d2 >= 0) or (d0 <= 0 and d1 <= 0 and d2 <= 0) + return f + +def rrect(x0, y0, x1, y1, r): + def f(x, y): + cx = min(max(x, x0+r), x1-r) + cy = min(max(y, y0+r), y1-r) + if x0 <= x <= x1 and y0 <= y <= y1: + return math.hypot(x-cx, y-cy) <= r or (x0+r <= x <= x1-r) or (y0+r <= y <= y1-r) + return False + return f + +def union(*shapes): + return lambda x, y: any(s(x, y) for s in shapes) + +def subtract(base, *holes): + return lambda x, y: base(x, y) and not any(h(x, y) for h in holes) + +# -- the icons ------------------------------------------------------------- + +# A teacup with steam: the Gitea mark, simplified enough to read at 16px. +def cup_body(x, y): + top, bottom = 0.44, 0.80 + if not (top <= y <= bottom): + return False + t = (y - top) / (bottom - top) + half = 0.26 * (1 - 0.42 * t) # tapers toward the base + if abs(x - 0.46) > half: + return False + if t > 0.82: # round the bottom corners off + return abs(x - 0.46) <= half * (1 - (t - 0.82) * 3.4) + return True + +GITEA = union( + cup_body, + arc(0.735, 0.575, 0.105, 0.055, 285, 75), # handle + rrect(0.16, 0.83, 0.78, 0.90, 0.035), # saucer + seg(0.38, 0.34, 0.38, 0.24, 0.055), # steam + seg(0.54, 0.34, 0.54, 0.20, 0.055), +) + +# Two commits joined by an elbow: the branch-filter control. +BRANCH = union( + disc(0.28, 0.26, 0.115), + disc(0.28, 0.76, 0.115), + disc(0.74, 0.42, 0.115), + seg(0.28, 0.26, 0.28, 0.76, 0.075), + seg(0.28, 0.44, 0.74, 0.44, 0.075), +) + +# A circular arrow: refresh. +REFRESH = union( + arc(0.5, 0.52, 0.30, 0.10, 20, 300), + tri((0.60, 0.14), (0.88, 0.24), (0.62, 0.38)), +) + +ICONS = { + "gitea-small": (GITEA, [16, 32]), + "gitea-large": (GITEA, [32, 64]), + "extension": (GITEA, [32, 64]), + "refresh": (REFRESH, [16, 32]), + "branch": (BRANCH, [16, 32]), +} + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +for name, (shape, sizes) in ICONS.items(): + folder = os.path.join(ROOT, "Images", name) + for index, size in enumerate(sizes): + suffix = "" if index == 0 else "@2x" + png(os.path.join(folder, f"{name}{suffix}.png"), size, size, render(size, shape)) + open(os.path.join(folder, "metadata.json"), "w").write('{\n "template": true\n}\n') + print("wrote", folder) diff --git a/extension.json b/extension.json new file mode 100644 index 0000000..be0ca20 --- /dev/null +++ b/extension.json @@ -0,0 +1,342 @@ +{ + "identifier": "unsupervised.giteanova", + "name": "Gitea", + "organization": "Unsupervised", + "description": "Gitea Actions, pull requests, reviews, secrets, and variables — without leaving Nova.", + "version": "1.0", + "license": "MIT", + "categories": ["sidebars", "commands", "issues"], + "repository": "https://git.unsupervised.ca/unsupervised/gitea-nova.novaextension", + "bugs": "https://git.unsupervised.ca/unsupervised/gitea-nova.novaextension/issues", + + "main": "main.js", + + "activationEvents": ["*"], + + "entitlements": { + "requests": true, + "process": true, + "filesystem": "readwrite", + "clipboard": true + }, + + "commands": { + "extensions": [ + { "title": "Refresh Gitea", "command": "gitea.refresh", "shortcut": "ctrl-alt-r" }, + { "title": "Set Token…", "command": "gitea.setToken" }, + { "title": "Sign Out…", "command": "gitea.clearToken" }, + { "title": "Test Connection", "command": "gitea.testConnection" }, + { "separator": true }, + { "title": "Add Gitea Instance…", "command": "gitea.addInstance" }, + { "title": "Remove Gitea Instance…", "command": "gitea.removeInstance" }, + { "title": "Open Gitea Preferences", "command": "gitea.openPreferences" }, + { "separator": true }, + { "title": "Change Branch Filter…", "command": "gitea.switchBranchFilter" }, + { "title": "Open Latest Failed Job Logs", "command": "gitea.openLatestFailedJobLogs" }, + { "separator": true }, + { "title": "Create Pull Request…", "command": "gitea.createPullRequest" }, + { "title": "Add Secret…", "command": "gitea.createSecret" }, + { "title": "Add Variable…", "command": "gitea.createVariable" } + ], + "editor": [ + { + "title": "Add Gitea Review Comment…", + "command": "gitea.addReviewComment", + "when": "editorHasFocus && documentHasPath" + } + ] + }, + + "sidebars": [ + { + "id": "gitea", + "name": "Gitea", + "smallImage": "gitea-small", + "largeImage": "gitea-large", + "sections": [ + { + "id": "gitea.branch", + "name": "Current Branch", + "placeholderText": "No Gitea repository detected in this workspace.", + "headerCommands": [ + { "title": "Refresh", "image": "refresh", "tooltip": "Refresh", "command": "gitea.refresh" }, + { "title": "Branch Filter", "image": "branch", "tooltip": "Change branch filter", "command": "gitea.switchBranchFilter" } + ], + "contextCommands": [ + { "title": "Open in Browser", "command": "gitea.openInBrowser", "when": "viewItem == 'run' || viewItem == 'job' || viewItem == 'pr' || viewItem == 'artifact' || viewItem == 'repo' || viewItem == 'review' || viewItem == 'comment'" }, + { "title": "Copy URL", "command": "gitea.copyUrl", "when": "viewItem == 'run' || viewItem == 'job' || viewItem == 'pr' || viewItem == 'artifact' || viewItem == 'repo' || viewItem == 'review' || viewItem == 'comment'" }, + { "separator": true }, + { "title": "View Job Logs", "command": "gitea.viewJobLogs", "when": "viewItem == 'job'" }, + { "title": "Re-run Workflow Run", "command": "gitea.rerunRun", "when": "viewItem == 'run'" }, + { "title": "Re-run Failed Jobs", "command": "gitea.rerunFailedJobs", "when": "viewItem == 'run'" }, + { "title": "Re-run Job", "command": "gitea.rerunJob", "when": "viewItem == 'job'" }, + { "title": "Cancel Run", "command": "gitea.cancelRun", "when": "viewItem == 'run'" }, + { "separator": true }, + { "title": "Download Artifact", "command": "gitea.downloadArtifact", "when": "viewItem == 'artifact'" }, + { "separator": true }, + { "title": "Open Pull Request Overview", "command": "gitea.openPullRequestOverview", "when": "viewItem == 'pr'" }, + { "title": "Open Pull Request Diff", "command": "gitea.openPullRequestDiff", "when": "viewItem == 'pr'" }, + { "title": "Checkout Branch", "command": "gitea.checkoutPrBranch", "when": "viewItem == 'pr'" }, + { "title": "Approve", "command": "gitea.reviewApprove", "when": "viewItem == 'pr'" }, + { "title": "Request Changes…", "command": "gitea.reviewRequestChanges", "when": "viewItem == 'pr'" }, + { "title": "Comment…", "command": "gitea.reviewComment", "when": "viewItem == 'pr'" }, + { "title": "Merge…", "command": "gitea.mergePullRequest", "when": "viewItem == 'pr'" }, + { "title": "Close", "command": "gitea.closePullRequest", "when": "viewItem == 'pr'" }, + { "separator": true }, + { "title": "Reply…", "command": "gitea.replyToReviewComment", "when": "viewItem == 'comment'" }, + { "title": "Resolve Thread", "command": "gitea.resolveReviewThread", "when": "viewItem == 'comment'" }, + { "title": "Unresolve Thread", "command": "gitea.unresolveReviewThread", "when": "viewItem == 'comment'" } + ] + }, + { + "id": "gitea.workflows", + "name": "Workflows", + "placeholderText": "No workflow runs found.", + "headerCommands": [ + { "title": "Refresh", "image": "refresh", "tooltip": "Refresh workflow runs", "command": "gitea.refreshRuns" } + ], + "contextCommands": [ + { "title": "Open in Browser", "command": "gitea.openInBrowser", "when": "viewItem == 'run' || viewItem == 'job' || viewItem == 'artifact' || viewItem == 'repo' || viewItem == 'workflow'" }, + { "title": "Copy URL", "command": "gitea.copyUrl", "when": "viewItem == 'run' || viewItem == 'job' || viewItem == 'artifact' || viewItem == 'repo' || viewItem == 'workflow'" }, + { "separator": true }, + { "title": "View Job Logs", "command": "gitea.viewJobLogs", "when": "viewItem == 'job'" }, + { "title": "Re-run Workflow Run", "command": "gitea.rerunRun", "when": "viewItem == 'run'" }, + { "title": "Re-run Failed Jobs", "command": "gitea.rerunFailedJobs", "when": "viewItem == 'run'" }, + { "title": "Re-run Job", "command": "gitea.rerunJob", "when": "viewItem == 'job'" }, + { "title": "Cancel Run", "command": "gitea.cancelRun", "when": "viewItem == 'run'" }, + { "separator": true }, + { "title": "Download Artifact", "command": "gitea.downloadArtifact", "when": "viewItem == 'artifact'" }, + { "title": "Open Artifact", "command": "gitea.openArtifact", "when": "viewItem == 'artifact'" }, + { "title": "Reveal Artifact in Finder", "command": "gitea.revealArtifact", "when": "viewItem == 'artifact'" }, + { "separator": true }, + { "title": "Refresh Repository", "command": "gitea.refreshRepo", "when": "viewItem == 'repo'" } + ] + }, + { + "id": "gitea.pullRequests", + "name": "Pull Requests", + "placeholderText": "No open pull requests.", + "headerCommands": [ + { "title": "Refresh", "image": "refresh", "tooltip": "Refresh pull requests", "command": "gitea.refreshPullRequests" }, + { "title": "Create", "image": "__builtin.add", "tooltip": "Create a pull request", "command": "gitea.createPullRequest" } + ], + "contextCommands": [ + { "title": "Open Pull Request Overview", "command": "gitea.openPullRequestOverview", "when": "viewItem == 'pr'" }, + { "title": "Open Pull Request Diff", "command": "gitea.openPullRequestDiff", "when": "viewItem == 'pr'" }, + { "title": "Open Changed File", "command": "gitea.openChangedFile", "when": "viewItem == 'prFile'" }, + { "separator": true }, + { "title": "Open in Browser", "command": "gitea.openInBrowser", "when": "viewItem == 'pr' || viewItem == 'repo' || viewItem == 'review' || viewItem == 'comment' || viewItem == 'prFile'" }, + { "title": "Copy URL", "command": "gitea.copyUrl", "when": "viewItem == 'pr' || viewItem == 'repo' || viewItem == 'review' || viewItem == 'comment' || viewItem == 'prFile'" }, + { "separator": true }, + { "title": "Checkout Branch", "command": "gitea.checkoutPrBranch", "when": "viewItem == 'pr'" }, + { "title": "Approve", "command": "gitea.reviewApprove", "when": "viewItem == 'pr'" }, + { "title": "Request Changes…", "command": "gitea.reviewRequestChanges", "when": "viewItem == 'pr'" }, + { "title": "Comment…", "command": "gitea.reviewComment", "when": "viewItem == 'pr'" }, + { "separator": true }, + { "title": "Merge…", "command": "gitea.mergePullRequest", "when": "viewItem == 'pr'" }, + { "title": "Close", "command": "gitea.closePullRequest", "when": "viewItem == 'pr'" }, + { "separator": true }, + { "title": "Reply…", "command": "gitea.replyToReviewComment", "when": "viewItem == 'comment'" }, + { "title": "Resolve Thread", "command": "gitea.resolveReviewThread", "when": "viewItem == 'comment'" }, + { "title": "Unresolve Thread", "command": "gitea.unresolveReviewThread", "when": "viewItem == 'comment'" } + ] + }, + { + "id": "gitea.settings", + "name": "Settings", + "placeholderText": "No Gitea instances configured.", + "headerCommands": [ + { "title": "Refresh", "image": "refresh", "tooltip": "Refresh settings", "command": "gitea.refreshSettings" }, + { "title": "Add Instance", "image": "__builtin.add", "tooltip": "Add a Gitea instance", "command": "gitea.addInstance" } + ], + "contextCommands": [ + { "title": "Set Token…", "command": "gitea.setToken", "when": "viewItem == 'instance'" }, + { "title": "Sign Out…", "command": "gitea.clearToken", "when": "viewItem == 'instance'" }, + { "title": "Test Connection", "command": "gitea.testConnection", "when": "viewItem == 'instance'" }, + { "title": "Remove Instance…", "command": "gitea.removeInstance", "when": "viewItem == 'instance'" }, + { "separator": true }, + { "title": "Refresh Secrets", "command": "gitea.refreshSecrets", "when": "viewItem == 'secretsFolder' || viewItem == 'secret'" }, + { "title": "Add Secret…", "command": "gitea.createSecret", "when": "viewItem == 'secretsFolder' || viewItem == 'secret'" }, + { "title": "Update Secret…", "command": "gitea.updateSecret", "when": "viewItem == 'secret'" }, + { "title": "Delete Secret…", "command": "gitea.deleteSecret", "when": "viewItem == 'secret'" }, + { "separator": true }, + { "title": "Refresh Variables", "command": "gitea.refreshVariables", "when": "viewItem == 'variablesFolder' || viewItem == 'variable'" }, + { "title": "Add Variable…", "command": "gitea.createVariable", "when": "viewItem == 'variablesFolder' || viewItem == 'variable'" }, + { "title": "Update Variable…", "command": "gitea.updateVariable", "when": "viewItem == 'variable'" }, + { "title": "Delete Variable…", "command": "gitea.deleteVariable", "when": "viewItem == 'variable'" }, + { "separator": true }, + { "title": "Open in Browser", "command": "gitea.openInBrowser", "when": "viewItem == 'instance' || viewItem == 'repo'" }, + { "title": "Copy URL", "command": "gitea.copyUrl", "when": "viewItem == 'instance' || viewItem == 'repo'" } + ] + } + ] + } + ], + + "config": [ + { + "key": "gitea.section.connection", + "title": "Connection", + "type": "section", + "children": [ + { + "key": "gitea.baseUrl", + "title": "Default Instance URL", + "description": "The Gitea instance used when a repository's remote host does not match any other configured instance.", + "type": "string", + "default": "https://gitea.com", + "placeholder": "https://gitea.com" + }, + { + "key": "gitea.instances", + "title": "Additional Instances", + "description": "Extra Gitea instance URLs. Repositories are routed to an instance by matching their git remote host. Tokens are stored per instance in the macOS Keychain.", + "type": "stringArray" + }, + { + "key": "gitea.setToken", + "title": "Sign In", + "description": "Store a personal access token for a configured instance.", + "type": "command", + "command": "gitea.setToken" + }, + { + "key": "gitea.testConnection", + "title": "Test Connection", + "description": "Verify the stored token against each configured instance.", + "type": "command", + "command": "gitea.testConnection" + } + ] + }, + { + "key": "gitea.section.discovery", + "title": "Repositories", + "type": "section", + "children": [ + { + "key": "gitea.discovery.mode", + "title": "Discovery Mode", + "description": "How repositories are found. \"Workspace\" reads git remotes from the open workspace. \"All Accessible\" lists every repository your token can reach.", + "type": "enum", + "values": [ + ["workspace", "Workspace Git Remotes"], + ["allAccessible", "All Accessible Repositories"] + ], + "default": "workspace" + }, + { + "key": "gitea.maxRunsPerRepo", + "title": "Max Runs per Repository", + "type": "number", + "default": 20, + "min": 1, + "max": 200 + }, + { + "key": "gitea.maxJobsPerRun", + "title": "Max Jobs per Run", + "type": "number", + "default": 50, + "min": 1, + "max": 200 + } + ] + }, + { + "key": "gitea.section.refresh", + "title": "Refresh", + "type": "section", + "children": [ + { + "key": "gitea.refresh.runningIntervalSeconds", + "title": "Active Interval (seconds)", + "description": "Polling interval while runs are queued or in progress.", + "type": "number", + "default": 15, + "min": 5, + "max": 3600 + }, + { + "key": "gitea.refresh.idleIntervalSeconds", + "title": "Idle Interval (seconds)", + "description": "Polling interval when nothing is running.", + "type": "number", + "default": 60, + "min": 10, + "max": 3600 + }, + { + "key": "gitea.notifications.failedRuns", + "title": "Notify on Failed Runs", + "description": "Post a notification when a workflow run finishes with a failure.", + "type": "boolean", + "default": true + } + ] + }, + { + "key": "gitea.section.review", + "title": "Reviews & Logs", + "type": "section", + "children": [ + { + "key": "gitea.reviewComments.enabled", + "title": "Show Review Comments Inline", + "description": "Surface pull request review comments for the current branch as issues in the editor gutter and the Issues sidebar.", + "type": "boolean", + "default": true + }, + { + "key": "gitea.jobLogs.saveToRepo", + "title": "Save Job Logs to Repository", + "description": "Write job logs to .tmp/gitea-logs/ inside the repository so they open as saved files. When off, logs open in an untitled tab.", + "type": "boolean", + "default": true + }, + { + "key": "gitea.artifacts.downloadPath", + "title": "Artifact Download Path", + "description": "Where downloaded artifacts are written. Relative paths resolve against the repository root.", + "type": "string", + "default": ".tmp/gitea-artifacts/" + } + ] + }, + { + "key": "gitea.logging.debug", + "title": "Enable Debug Logging", + "description": "Write verbose diagnostics to the extension console.", + "type": "boolean", + "default": false + } + ], + + "configWorkspace": [ + { + "key": "gitea.baseUrl", + "title": "Instance URL", + "description": "Override the default Gitea instance for this workspace.", + "type": "string", + "placeholder": "Inherit from global preferences" + }, + { + "key": "gitea.branchFilter", + "title": "Branch Filter", + "description": "Which branch the Current Branch section tracks.", + "type": "enum", + "values": [ + ["current", "Checked-out Branch"], + ["all", "All Branches"] + ], + "default": "current" + }, + { + "key": "gitea.branchFilter.custom", + "title": "Custom Branch", + "description": "When set, the Current Branch section tracks this branch instead of the checked-out one.", + "type": "string", + "placeholder": "e.g. main" + } + ] +}