Discovery matched a git remote's host against the instance URL's host, so a server answering SSH on a different name than its web UI resolved to nothing — and said so only in a debug log. Resolution now runs in three stages. A remote host is matched directly, then against configured aliases, and failing both the instances are asked for the repository: Gitea publishes its SSH hostname in a repository's ssh_url, so the right instance identifies itself. What that turns up is saved as a host alias, so later repositories on the same host resolve with no lookup at all, and the mapping is visible and editable rather than hidden. Each unknown host is probed at most once per session. When nothing resolves the sidebar now names the unmatched host and offers Add Host Alias, instead of showing an empty section. Aliases can also be written by hand as "remote-host = instance URL", accepting =, -> and =>, ignoring ports, and skipping # comments. List preferences now merge workspace entries onto global ones rather than letting an empty global array mask them. Adds Tests/host-aliases.test.js covering both directions: unmatched hosts reported and nothing persisted, a hand-written alias, detection from ssh_url, and a later repository resolving from the stored alias without a probe. 131 checks across three suites. Also adds CLAUDE.md, and .gitea/workflows/ci.yml running the suites, script syntax checks, manifest validation, and a generated-image check. Tools/make-icons.py gains --check, which compares decompressed pixels so a differing zlib version cannot fail it spuriously. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MQuusXgZC2dzwpJJ1qhtti
204 lines
9.6 KiB
Markdown
204 lines
9.6 KiB
Markdown
# CLAUDE.md
|
|
|
|
Working notes for this extension. The [README](README.md) is for users; this is
|
|
for whoever picks the code up next. It records the things that are not obvious
|
|
from reading the source.
|
|
|
|
## What this is
|
|
|
|
A Nova (Panic's macOS editor) extension giving Gitea Actions, pull requests,
|
|
reviews, secrets, and variables a sidebar. It aims at feature parity with the
|
|
official [Gitea for VS Code](https://gitea.com/gitea/gitea-vscode) extension,
|
|
adapted to what Nova's API actually allows.
|
|
|
|
Plain CommonJS. No build step, no bundler, no dependencies — Nova runs
|
|
`Scripts/` directly.
|
|
|
|
## Verifying a change
|
|
|
|
```sh
|
|
Tests/run.sh # 131 checks, ~3s, no network
|
|
find Scripts Tests -name '*.js' -print0 | xargs -0 -n1 node --check
|
|
python3 -c "import json; json.load(open('extension.json'))"
|
|
```
|
|
|
|
Nova itself will not tell you about a malformed `extension.json` beyond refusing
|
|
to load, so validate it. `.gitea/workflows/ci.yml` runs exactly these checks
|
|
plus `python3 Tools/make-icons.py --check`, which verifies the committed images
|
|
still match the shapes they are generated from. That check compares
|
|
decompressed pixels rather than file bytes, because zlib output differs between
|
|
versions and a byte comparison would fail spuriously.
|
|
|
|
If CI fails on the runner label, `runs-on` in that workflow is the only thing to
|
|
change — the jobs need nothing but node, python3, and git.
|
|
|
|
To try the real thing:
|
|
|
|
```sh
|
|
ln -s "$PWD" ~/Library/Application\ Support/Nova/Extensions/unsupervised.giteanova
|
|
```
|
|
|
|
Then **Extensions → Extension Library → Gitea → Console**, with **Enable Debug
|
|
Logging** on.
|
|
|
|
## Layout and layering
|
|
|
|
```
|
|
Scripts/
|
|
main.js activate/deactivate, TreeViews, preference observers
|
|
store.js all state; polling; the only thing that fetches on a timer
|
|
ui.js promise wrappers for Nova's callback panels; file helpers
|
|
util/ log, config, time, exec, git (no internal deps)
|
|
gitea/ remotes, client, api, credentials, router, discovery
|
|
views/ nodes, provider, builders, one file per sidebar section
|
|
controllers/ one file per command group; selection.js normalises arguments
|
|
```
|
|
|
|
Dependencies run one way, and it is worth keeping them that way:
|
|
|
|
```
|
|
util → gitea → store → views → controllers → main
|
|
```
|
|
|
|
`util/` requires nothing internal. `views/builders.js` requires `store.js` only
|
|
for `isActiveRun`; that is the deepest upward reach in the codebase.
|
|
|
|
## The pieces that matter
|
|
|
|
**`store.js`** is the single source of truth. Views read it and never fetch.
|
|
Controllers mutate through it. It owns:
|
|
|
|
- `repos`, `unmatchedHosts`, `instanceStatus`
|
|
- per-run `jobs`/`artifacts`, per-PR `pullRequestDetail`, `secrets`, `variables`
|
|
- `onDidChange` → `main.js` debounces 150ms → every provider's `invalidate()` +
|
|
`reload()`
|
|
- adaptive polling: `runningInterval` when anything is queued or running, else
|
|
`idleInterval`. `refresh()` coalesces — a second call while one is in flight
|
|
returns the same promise rather than stacking.
|
|
|
|
**`views/provider.js`** — every section subclasses `NodeProvider` and implements
|
|
only `roots()`. Nodes are plain objects (`views/nodes.js` `node()`), turned into
|
|
`TreeItem`s centrally. A node with `load` fetches when expanded; the result is
|
|
cached onto `element.children`.
|
|
|
|
**`controllers/selection.js`** — a command can arrive from a sidebar (Nova
|
|
passes the `TreeView`), from a node directly, or from a menu with no argument at
|
|
all. `selectedNode()` normalises all three; `resolveRepo()` falls back to the
|
|
only repo, or prompts. Use these rather than reading arguments yourself.
|
|
|
|
## Nova API constraints, learned the hard way
|
|
|
|
- **`when` clauses are a strict subset of JavaScript.** `&&`, `||`, `!`, `==`,
|
|
`!=`, parentheses. **No regex, no `in`.** Write
|
|
`viewItem == 'a' || viewItem == 'b'`. A bad clause fails silently.
|
|
- **No webview, no diff editor, no editor decorations, no status bar** for
|
|
extensions. Hence: the PR timeline is Markdown in a tab, diffs are `.diff`
|
|
documents, inline review comments go through `IssueCollection`, and a failed
|
|
run posts a notification.
|
|
- **Images** live at `Images/<name>/<name>.png` + `<name>@2x.png` +
|
|
`metadata.json` with `{"template": true}`. Referenced by folder name. All of
|
|
`Images/` is generated by `Tools/make-icons.py` — edit the shapes there and
|
|
re-run it, don't hand-edit PNGs. CI enforces this with `--check`.
|
|
- **Entitlements** in `extension.json` gate `fetch`, `Process`, filesystem, and
|
|
clipboard. Adding a capability means adding the entitlement.
|
|
- `fetch`, `setTimeout`, `TextEncoder`, `atob`/`btoa` exist. There is no `URL`
|
|
guarantee — `gitea/client.js` builds query strings by hand for that reason.
|
|
- `nova.credentials` is a **synchronous Keychain call**. See caching below.
|
|
- `TreeItem.command` fires on double-click and receives the `TreeView`.
|
|
- Menu separators are `{"separator": true}`.
|
|
|
|
## Caching invariants
|
|
|
|
Three caches, each with an invalidation rule that is easy to break:
|
|
|
|
1. **`gitea/credentials.js` memoises tokens.** The sidebar asks `hasToken()` on
|
|
every render, and each miss is a synchronous Keychain hit. **Always go
|
|
through `credentials.setToken`/`removeToken`** — writing `nova.credentials`
|
|
directly leaves the memo stale. (A test did exactly this and produced a
|
|
convincing-looking failure that was not a product bug.)
|
|
2. **`InstanceRouter`** caches API clients, the parsed alias map, and the set of
|
|
already-probed hosts. `router.invalidate()` clears all three plus the token
|
|
memo. Call it after anything that changes instance configuration.
|
|
3. **`store`** caches run detail and PR detail. The lazy loaders in
|
|
`views/builders.js` reuse them so a 15-second reload does not refetch every
|
|
expanded row — a run refetches only while still active, a PR only when its
|
|
`updated_at` moves. Preserve that when touching the loaders.
|
|
|
|
`store._pruneCaches()` derives repo keys by slicing at the **last** `:` — repo
|
|
keys contain `://`, so it has to be `lastIndexOf`.
|
|
|
|
## Host aliases
|
|
|
|
Some Gitea instances answer SSH on a different hostname than the web UI, which
|
|
breaks host-based routing. Resolution order in `discovery.js`:
|
|
|
|
1. `router.baseUrlForHost(host)` — direct match, then the alias map.
|
|
2. `router.detectHostAlias(host, owner, name)` — asks each tokened instance for
|
|
the repository and compares the `ssh_url`/`clone_url`/`html_url` it
|
|
advertises. On a match it persists a `host = instance` entry via
|
|
`config.addHostAlias` and returns the instance.
|
|
3. Otherwise the host lands in `store.unmatchedHosts` and the sidebar shows a
|
|
row offering `gitea.addHostAlias`.
|
|
|
|
Each host is probed at most once per session (`_probedHosts`). `addHostAlias`
|
|
returns `false` and writes nothing when the preference already says this — that
|
|
matters because `gitea.hostAliases` is in `main.js`'s `REDISCOVERY_KEYS`, and an
|
|
unconditional write would re-trigger discovery.
|
|
|
|
## Adding a command
|
|
|
|
1. `nova.commands.register("gitea.thing", handler)` in the right controller.
|
|
2. Add it to `extension.json` — a `commands.extensions`/`commands.editor` entry,
|
|
a sidebar `headerCommands`/`contextCommands` entry, or a `config` button.
|
|
3. If it acts on a selection, give the node a `contextValue` and gate it with a
|
|
`when` clause.
|
|
|
|
`integration.test.js` asserts both directions: every manifest command is
|
|
registered, and every registered command appears in the manifest. The single
|
|
allowed exception is `gitea.openReviewComment`, a double-click handler with no
|
|
menu entry — if you add another, update that assertion deliberately.
|
|
|
|
## Gitea API notes
|
|
|
|
Endpoints were taken from Gitea's published `swagger.v1.json` (fetch it from any
|
|
instance at `/swagger.v1.json`). Worth knowing:
|
|
|
|
- Re-running one job is `POST /actions/runs/{run}/jobs/{job_id}/rerun` — under
|
|
the run, not under `/actions/jobs/`.
|
|
- `/actions/runs` arrived in Gitea 1.24. `api.listRuns` catches the 404 and
|
|
falls back to `/actions/tasks`, flagged as `legacy: true`, which the views
|
|
render as a flat "Recent Runs" list. Keep that fallback.
|
|
- Secrets are `PUT` (create and update alike) and never read back — an "update"
|
|
is just another `PUT`. Variables use `POST` to create and `PUT` to update.
|
|
- `/actions/artifacts` and `/actions/workflows` 404 on older servers;
|
|
`listArtifacts`/`listWorkflows` return `[]` rather than throwing, so one old
|
|
server does not blank the tree.
|
|
- Review comments carry `diff_hunk`, not a file line.
|
|
`controllers/review.js` `lineFromDiffHunk` derives the line: the commented
|
|
line is the hunk's last, and `-` lines do not advance the new file. This is
|
|
easy to get wrong — check it by hand before assuming a test is wrong.
|
|
- Go's zero time (`0001-01-01T00:00:00Z`) means "never". `util/time.js` returns
|
|
`""` for it; never render it as a date.
|
|
|
|
## Tests
|
|
|
|
Three suites under `Tests/`, run with `Tests/run.sh`. No npm install, no
|
|
network: `nova-stub.js` stands in for the Nova runtime and `fake-gitea.js`
|
|
serves a canned Gitea 1.27 through a `fetch()` stub. Git fixtures are built in
|
|
`mkdtemp` directories and removed afterwards — nothing is written inside the
|
|
repo. See [Tests/README.md](Tests/README.md).
|
|
|
|
When adding coverage, prefer extending `fake-gitea.js`'s fixtures over mocking a
|
|
module: the suites exercise the real code paths, which is what makes them worth
|
|
having.
|
|
|
|
## Deliberately not implemented
|
|
|
|
- **OAuth sign-in.** Gitea's flow needs a redirect to a local HTTP listener;
|
|
Nova extensions cannot listen on a socket. Tokens only.
|
|
- **`tls.insecureSkipVerify`.** Nova's `fetch` exposes no TLS override, so the
|
|
setting would be a lie. Omitted rather than shipped as a no-op.
|
|
|
|
Both are recorded in the README's comparison table. If Nova ever gains the
|
|
capability, that table is the place to start.
|