Compare commits

...

20 Commits

Author SHA1 Message Date
Vitor Pamplona
5e5ecdab8b Merge PR: NIP-34 git parity for amy + quartz interop fixes
Merges nostr proposal 2ca4f8ae into main:
- feat(cli): full NIP-34 git collaboration parity for `amy git`
- feat(cli): `amy git grasp list|set` (NIP-34 GRASP server list, kind 10317)
- feat(cli): `amy git browse|cat|log` — read git objects over smart-HTTP
- feat(cli): `amy git init` — bootstrap a repo from the local git checkout
- feat(cli): `amy git label` (NIP-32) and `amy git apply` (patch -> working tree)
- fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
- test(cli): live interop check against the real ngit-published amethyst repo
- fix(cli): shallow-clone euc, read truncation, process deadlock
- fix(cli): read routing, status perf, publish-ack + robustness
- fix: complete PR clone multi-value (kinds 1618/1620) + CLI robustness
- revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal

clone/web now serialize as single multi-value tags per NIP-34 (ngit drops
repeated ones) for kinds 30617, 1618 and 1620; readers stay tolerant of the
legacy repeated form.

The GitStatusIndex status-authority guard was reverted before merge: it
authorized against the status event's own `a` tag, which the author controls,
so a forged ["a","30617:<attacker>:x"] defeated it — and unverifiable statuses
were dropped permanently (no re-reduce when the 30617 announcement arrives),
rendering closed issues as open. Refiling as its own proposal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:54:49 -04:00
Claude
062ab0f012 revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal
The isAuthoritative guard added to GitStatusIndex read the repository owner
from the status event's own `a` tag (GitStatusEvent.repositoryAddress() ->
first a tag, no kind filter, no cross-check against the target's repo). That
value is attacker-controlled: a forged kind:1632 carrying
`["a", "30617:<attacker-pubkey>:anything"]` makes `status.pubKey ==
repoAddress.pubKeyHex` pass, so the spoof it meant to block still succeeds.

It also regressed reads: reduceLatestByTarget only re-runs on a new
kind 1630-1633, so a status dropped while the 30617 was uncached stayed
dropped, leaving genuinely-closed items in the Open tab with wrong counts.

Keep this series focused on the quartz + cli NIP-34 parity work. The Android
status-authorization hardening (resolve the repo from the target item, load
the cached 30617, authorize against repo.pubKey + maintainers() + the target
author, and re-reduce when a 30617 arrives) will land as its own proposal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:38:20 +00:00
Claude
5f5356c0fe fix: complete PR clone multi-value, git-status auth spoofing, CLI robustness
Addresses review findings from a merge-time audit.

- **Complete the headline multi-value `clone` fix for PRs** (was applied only to
  kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619)
  carry `clone` with the same spec shape but still emitted repeated single-value
  tags and read only the first value — so the exact interop bug this branch set
  out to kill was still live for PRs, both directions (ngit keeps only the last
  repeated tag; we lost every URL after the first from ngit's multi-value tag).
  Now both emit one multi-value `["clone", …]` tag and read both forms. Verified
  on the wire + GitNip34InteropTest + CLI harness (40 checks).

- **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no
  author check meant anyone could publish a kind-1632 and make someone else's
  issue render closed. Now filter statuses to the repository owner (from the
  status's own `a` tag), declared maintainers (from the cached announcement), or
  the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing
  on main; this branch made the CLI/Android divergence visible.

- **CLI robustness**: `git comment`/`git patch` no longer block forever reading
  stdin on an interactive TTY (amy is non-interactive — error instead). The local
  `git` subprocesses in `git init`/`git apply` now drain stdout on a side thread
  under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the
  CLI.

Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates
GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the
cross-module coupling here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
4e0ac212ee fix(cli): second audit pass — read routing, status perf, publish-ack + robustness
Findings from a second review round (two independent reviewers), with fixes:

Read path (git issues/patches/prs/thread):
- **Reads ignored the repo's own relays** (correctness). They queried only the
  account outbox/bootstrap (general relays); NIP-34 events live on the repo
  announcement's advertised relays (often GRASP/git-specific), which general
  relays don't mirror — so `amy git issues <repo>` with no --relay could return
  empty. Now fetch the announcement once and read from queryTargets ∪ its
  advertised `relays`. Verified live: `git issues`/`git prs` on the amethyst
  repo now return real events (and derive `closed`) with NO --relay.
- **O(items × statuses) status rescan** with un-memoized `rootEventId()` reparse
  → pre-group statuses by root id once (O(1) lookup per item).
- **Status query could truncate / exceed relay caps**: statuses are now paged
  (`drainAllPages`) and the `#e` id set is chunked to 50 (under the common
  ~100-value relay filter cap).
- **Latency regression**: capped the list `drainAllPages` idle timeout to 12s
  (was the 30s default; `drain` had been 8s).
- **Nondeterministic status on same-second ties** → deterministic id tie-break.
- Reuse the fetched repo for the maintainer set (removes a redundant round-trip).

Write path:
- **`git init` silently reported success when the 30618 state publish failed**
  — its ack was dropped. Now surfaced as `state_published_to`/`state_rejected_by`
  with a stderr warning on total rejection.
- **`git apply`** feeds stdin as UTF-8 (was JVM default charset — corrupted
  non-ASCII patches) and joins the stdin thread in `finally` (no leak on error).
- **`normalizeCloneUrl`** drops the port from `ssh://git@host:port/…` (it was
  carried into the https URL, making it unreachable).
- **Delivery fallback** to the account outbox (repo unresolved / no advertised
  relays) now warns to stderr instead of reporting silent success.

Known limitation (documented, not fixed): patch-revision-chain status derivation
follows only the root item, and `git thread` shows first-level replies only
(nested trees and 1619 PR-updates are out of scope). 38/38 harness green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
63d01c9287 fix(cli): audit fixes — shallow-clone euc, read truncation, process deadlock
Findings from a review pass over the git-parity branch, with fixes:

- **`git init` announced a WRONG earliest-unique-commit on shallow clones**
  (interop-critical). `git rev-list --max-parents=0 HEAD` returns the shallow
  boundary commits, not the true root, so the repo would be announced under a
  different cross-fork identity than ngit computes. Now: detect shallow clones
  and omit the euc with a warning to pass `--earliest-commit`; on full clones
  derive the deterministic `--first-parent` mainline root instead of an
  arbitrary `tail -1`.

- **`git issues|patches|prs` silently truncated and mis-derived status** on
  active repos: one single-page `drain` pulled items AND status events under a
  shared cap, so status events (newer, more numerous) could crowd items out of
  the window and the close-status that determines an item's state could fall
  outside it → a closed item read as open. Now paginate the items
  (`drainAllPages`) and fetch exactly the statuses that `e`-reference them.
  Verified on the live amethyst repo: 51 PRs paginated, 19 correctly closed.

- **Pipe-buffer deadlocks** (latent): `GitInitCommand.git()` discards stderr to
  the OS (a chatty command can no longer fill its stderr pipe and hang the
  stdout read); `GitApplyCommand.runGit()` writes stdin on a background thread
  while draining stdout, so a patch larger than the pipe buffer can't deadlock.

- Minor: `git cat` binary detection uses an index loop instead of boxing 8000
  bytes; `GitRepositoryEvent.clones()/webs()` dedupe.

The harness `git init` test now runs against a fresh full checkout (this repo's
CI checkout is shallow) and adds a shallow-clone case asserting the euc is
omitted. 38/38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
fa32ea50a3 test(cli): live interop check against the real ngit-published amethyst repo
Adds a `--live` block that reads the actual amethyst repository ngit publishes
to relay.ngit.dev and asserts our reader parses ngit's real multi-value `clone`
tag (currently 4 URLs) plus its published issues. This is the real-world proof
of the multi-value interop fix: the pre-fix reader would have surfaced only the
first clone URL. Opt-in (needs network + the live relay), skipped by default.

Verified manually end-to-end against the live repo: repo announcement (4 clone
URLs), issues (1621), patches (1617), pull requests (1618, with a real `closed`
status derived from ngit's status event), and a NIP-22 comment via `git thread`
all read correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
1a77c95531 fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
Verified Amethyst's NIP-34 events byte-for-byte against the ngit reference
implementation (DanConwayDev/ngit-cli) and the spec, and fixed three real
interop divergences in quartz — so ngit/gitworkshop and Amethyst read each
other's git repos, issues, patches, and PRs without losing data.

- Repository announcement `clone`/`web` were emitted as REPEATED single-value
  tags (`["clone", a]`, `["clone", b]`). The spec and ngit use ONE multi-value
  tag (`["clone", a, b]`), and ngit's parser keeps only the LAST of repeated
  known tags — so multi-URL repos silently lost every URL but one in both
  directions. Now emitted as a single multi-value tag; `clones()`/`webs()` read
  BOTH the spec form and the legacy repeated form, so old events still parse.
  (`relays`/`maintainers` were already correct multi-value tags.)

- Issues (kind 1621) were missing the `["p", <repo-owner>]` tag that patches and
  PRs already include — a maintainer watching `#p` wouldn't see them. The
  builder now adds it (fixes both the CLI and the Android issue-creation path,
  which both passed an empty notify list).

- Patch / PR / PR-update `r` tags carried the `"euc"` marker
  (`["r", commit, "euc"]`). Per the spec and ngit that marker belongs only on
  the kind-30617 announcement; other `r` tags are plain `["r", commit]`. A `#r`
  filter matches either shape, so this is a spec-compliance/byte-parity fix.

`alt` (NIP-31) tags are intentionally still omitted — quartz treats the generic
alt client-hint as deprecated, and ngit/gitworkshop parse the structured tags,
so it isn't required for interop.

Adds `GitNip34InteropTest` (5 cases: multi-value write, tolerant read of both
forms, issue p-tag, plain patch r-tag) and 4 wire-format assertions to the CLI
git harness (37 offline). No regressions in the nip34 or Search suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
d3e208322c feat(cli): add amy git label (NIP-32) and amy git apply (patch → working tree)
Close two more ngit/nak parity gaps:

- `git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to an
  issue/patch/PR (the `ngit pr label` / `issue label` surface), over quartz's
  existing `LabelEvent`. Namespace defaults to `ugc`; `--namespace` overrides.
- `git apply PATCH_ID` — fetch a kind:1617 patch and apply it to the local
  working tree via `git am` (the `nak git patch apply` / `ngit pr apply`
  surface); `--check` dry-runs `git apply --check`, `--print` emits the patch.
  Shells out to `git` like `git init`, since it operates on the local checkout.

Verified end-to-end: a patch published to a relay, fetched, and `git am`'d as a
real commit into a scratch repo; labels land as kind 1985. The harness gains 5
assertions (label + a full publish→apply round-trip), now 33 offline.

Remaining out-of-scope items are documented: git-packfile push (needs a git
write layer quartz lacks) and NIP-34 cover notes (kind 1624, no quartz builder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
b06184ac49 feat(cli): add amy git init — bootstrap a repo from the local git checkout
Match `ngit init` / `nak git init`: read the local git repository and publish a
NIP-34 repository announcement, deriving the fields instead of making the user
type them. Shells out to `git` to determine the name (top-level dir), clone URL
(origin remote, ssh→https normalized), earliest-unique-commit (root commit),
and — for the accompanying kind:30618 state — the branch/tag tips and HEAD.
Publishes the 30617 announcement and (unless `--no-state`) the 30618 state in
one shot. Every derived value is overridable with a flag; outside a git repo
the derivation is skipped and `--name`/`--clone` are supplied manually.

This is the one `amy git` verb that shells out to `git`, since it is inherently
about the local working tree — exactly like the tools it mirrors.

Verified against the amethyst checkout itself (derives name=amethyst, the origin
clone URL, the root commit as EUC, and a 30618 with the live branches + HEAD).
The harness gains 4 assertions driving `git init` against its own checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
45d33c016e feat(cli): add amy git browse|cat|log — read git objects over smart-HTTP
Give `amy git` the git-object read side of `nak git download` / a shallow
clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at
a ref, and `log` shows recent commit history — all over the git smart-HTTP v2
protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android
repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement
supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref`
override the URL and branch/tag.

Read-only: pushing git objects back to clone/GRASP servers stays out of scope.

Verified live against a public repo (octocat/Hello-World) — browse/cat/log all
return correct trees, blobs, and history. The harness gains a `--live` block
(28 assertions with `--live`, 24 in the default offline run) exercising these
against `$LIVE_REPO`, skipped by default since it needs a reachable git host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
9922eef9f7 feat(cli): add amy git grasp list|set (NIP-34 GRASP server list, kind 10317)
Declare/read a user's preferred GRASP (Git-over-Nostr hosting) servers in
preference order — the NIP-65-style list `ngit`/`nak git` consult to decide
where PR tip branches (`refs/nostr/<pr-id>`) get pushed. `set` publishes a
kind:10317 to the outbox; `list` reads it back cache-first (anonymous-capable).
Thin assembly over quartz `UserGraspListEvent`. The git push itself stays out
of scope, as with the rest of the packfile transport.

Extends the git NIP-34 harness with a grasp round-trip (24 assertions) and
updates the README/ROADMAP/help tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
ad66e6c224 feat(cli): full NIP-34 git collaboration parity for amy git
Extend `amy git` from repo announce/list/show/issue to the complete
pure-Nostr surface of `ngit` and `nak git`, so every NIP-34 collaboration
flow is scriptable without a GUI.

New sub-verbs (all thin assembly over quartz's `nip34Git` builders):

- `git state`   — kind:30618 repository state (branch/tag tips + HEAD)
- `git patch`   — kind:1617 patch from `git format-patch` (--file or stdin),
                  with --root/--root-revision, --commit, --parent-commit,
                  and --in-reply-to for revision chains
- `git pr` / `git pr-update` — kind:1618 pull request + kind:1619 tip update
- `git comment` — NIP-22 kind:1111 reply on an issue/patch/PR/repo (the
                  modern replacement for the deprecated kind:1622 git reply)
- `git open|applied|close|draft` — kind:1630/1631/1632/1633 status events
                  (aliases `merged`/`resolved` for applied); applied carries
                  --merge-commit / --commit / --patch
- `git issues|patches|prs` — list a repo's items with status derived from the
                  newest authoritative (owner/maintainer/author) status event,
                  with --open/--applied/--closed/--draft/--status filters
- `git thread`  — one item plus its status timeline and comments

Shared parsing/fetch/routing glue lives in `GitSupport`; the existing
announce/list/show/issue verbs now reuse it. The git *packfile* transport
(clone/fetch/push of real objects to clone/GRASP servers) stays out of
scope — it needs a git plumbing layer, not an event builder — and is
documented as such.

Adds `cli/tests/git/git-nip34-headless.sh` (21 assertions, drives the whole
flow against `amy serve` and checks the status-deriving reads) and updates
the README/ROADMAP command tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Vitor Pamplona
da451145af Merge pull request #3653 from vitorpamplona/claude/notifications-pagination-n0qahe
Add infinite-scroll backward pagination for notification history
2026-07-20 20:10:05 -04:00
Claude
a5ac06ad6c Merge branch 'main' into claude/notifications-pagination-n0qahe
Bring the notifications-pagination feature up to date with main.

Conflict resolution — the two live-notification managers:
main independently fixed the "notifications capped at a week" bug by a
different route: it dropped the oneWeekAgo() floor and now runs an all-time
`#p`+`limit` query gated by the lastNoteCreatedAtIfFilled() paging boundary
(kept together with its lastNoteCreatedAtWhenFullyLoaded collector job). That
updateFilter + newSub pair is one self-consistent unit, so this merge takes
main's complete version of AccountNotificationsEoseFrom{Inbox,Random}
RelaysManager and keeps the branch's dedicated `until`+`limit` history pager as
an additive layer on top (Account.notificationHistory, the history manager,
NotificationHistoryPaging.kt, the markers/retry UI, filter builders, tests).

Net: the feed gets main's all-time live query plus the branch's unbounded
backward pager. Note the two now overlap for users under the relay limit — the
pager's remaining unique value is scrolling past that limit; worth a review
pass, not a merge blocker.

Verified: :amethyst:compilePlayDebugKotlin, spotlessApply (clean), and the
notification unit tests (FilterNotificationsHistoryTest,
NotificationKindsContractTest) all green on the merged tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-21 00:00:28 +00:00
Claude
77908b2be0 refactor(notifications): extract history paging out of CardFeedView
CardFeedView had grown a ~90-line paging block plus an auto-retry loop,
constants and helpers that aren't about rendering cards. Move all of it into
a dedicated NotificationHistoryPaging.kt:

- rememberNotificationHistoryPaging(): the look-ahead buffer driver (with the
  per-burst cap), the stalled-relay auto-retry loop, cursor building, and the
  per-relay sentinels — returns the List<RelayReachCursor> the feed draws.
- BootstrapNotificationHistoryWhenEmpty(): the empty-feed hunt.
- The five tuning constants and the reachState / relayShortName helpers.

CardFeedView.FeedLoaded now just fetches the pager, calls the helper for the
cursors, and renders the detail dialog; the in-gap RelayReachMarkers stay
inline (they're per-row). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:15:06 +00:00
Claude
05426d8c66 fix(notifications): bound eager fill + only the active feed drives paging
Address the two audit findings on the infinite-scroll driver.

- Bound the eager fill (#1): the buffer targets 100 rows but pages are pulled
  in events and notifications collapse heavily into cards, so on a dense
  account a fill could keep pulling until it downloaded the whole history to
  reach the row target. Cap consecutive pages pulled WITHOUT scrolling
  (NOTIFICATION_MAX_PAGES_PER_BURST); scrolling resets the budget, so paging
  resumes as the buffer is consumed. Appended older cards don't move
  firstVisibleItemIndex, so the from-top preload still fills the full
  look-ahead on open — only a dense whale is bounded.
- Only the active feed drives (#2): add drivesPaging (default true); the split
  screen passes page == pagerState.currentPage so an off-screen tab composed
  during a swipe no longer drives the shared account pager, and its buffer
  driver / auto-retry loop / sentinels stay idle. Single screen and side panel
  keep driving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:07:30 +00:00
Claude
c8f43a1bd4 feat(notifications): auto-retry faulty relays + actionable relay detail
Improve the notifications history UX around slow/unreachable relays, keeping
the per-relay markers (they let users notice their own bad relays) but making
recovery automatic and the tap-through actionable.

- Auto-retry stalled relays with backoff (~3s→30s): once the buffer driver
  stops (every relay done-or-stalled) but some are merely stalled, keep
  re-advancing them so recovery no longer depends on the user scrolling to the
  marker or reopening the screen. One non-restarting effect so the backoff
  survives the transient in-flight blips each retry causes; cancels on leave.
- Add a "Try Again" action to RelayReachDetailDialog (shared): when a caller
  passes onRetry and a relay is stalled, the tapped marker's detail popup
  offers an active retry and drops the now-inaccurate "retries on reopen" hint.
  Notifications wire it to advanceAll; DM callers pass nothing (unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 20:36:43 +00:00
Claude
cc94ef9103 fix(notifications): retry stalled relays via per-relay sentinels
The buffer-only driver stopped paging once every relay was done-or-stalled
(exhausted), so a transient all-relays blip halted history until re-navigation
— bad for faulty relays with different datasets, exactly when we'd miss data.

Restore the per-relay RelayReachSentinels alongside the look-ahead buffer:
- the buffer driver (advanceAll) keeps the runway full from healthy relays;
- the sentinels retry an individual relay when its frontier marker scrolls
  into view — the recovery path once the buffer can't keep the frontier ahead
  (relays stalled/exhausted), naturally rate-limited by scrolling.

The buffer keeps the frontier ~a screen below the fold, so the sentinels stay
quiet during normal scrolling and only fire on stall/end. This also makes the
kept per-relay markers functional again (they drive the retry) instead of
purely decorative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 20:14:30 +00:00
Claude
ad75344c3e feat(notifications): infinite-scroll paging via a look-ahead buffer
Switch the notifications feed from marker-visibility paging (load only when
the bottom marker is on screen) to infinite scroll: keep ~100 already-loaded
rows below the viewport so the user practically never reaches the end.

- Replace RelayReachSentinels with a buffer-depth driver: when fewer than
  NOTIFICATION_LOOKAHEAD_BUFFER (100) rows remain ahead of the last visible
  one, step every not-done relay one older page (advanceAll). It re-fires as
  each page settles until the buffer refills or all relays run dry — the
  wallet's lastVisibleIndex >= totalItems - N pattern with a large N.
- Keep the per-relay BackwardRelayPager engine, cursors and filters unchanged.
- Keep the in-feed per-relay progress markers + tap-through detail dialog;
  they are now purely visual (loading is driven by the buffer, not by them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 19:48:27 +00:00
Claude
ba8de9ba21 feat(notifications): paginate history by time with in-feed load markers
Notifications were pinned to the recent week with no way to scroll further
back. Mirror the NIP-04 / gift-wrap DM approach: a backward, per-relay
until+limit pager driven by in-feed window-limit markers that pull the next
older page only while visible.

- Add Account.notificationHistory (RelayLoadingCursors) and
  AccountNotificationsHistoryEoseManager, a BackwardRelayPager over the
  inbox + NIP-29 group-host relays, registered always-on in
  AccountFilterAssembler. It parks until a marker advances a relay.
- Add filterNotificationsHistoryToPubkey / filterGroupNotificationsHistoryToPubkey
  and AllNotificationKinds: one combined-kinds filter per relay so the single
  per-relay cursor stays gap-proof (empty page + EOSE = nothing older).
- Wire RelayReachMarkers + RelayReachSentinels into the notifications card
  feed (CardFeedView), with an empty-feed bootstrap, so scrolling a relay's
  marker into view loads more.
- Fix the two live notification loaders to a fixed one-week tail (drop the
  fullness-driven `since` drift), letting the marker-driven pager cleanly own
  everything older — the same live-tail/history split DMs use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 15:53:32 +00:00
38 changed files with 3341 additions and 124 deletions

View File

@@ -209,6 +209,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -660,6 +661,11 @@ class Account(
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
// Account-level notification history paging cursors (one scope per account): how far back each
// notification relay has been paged by until+limit. Held here so they share the account's lifetime;
// the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these.
val notificationHistory = RelayLoadingCursors()
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,

View File

@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
@@ -56,13 +57,20 @@ class AccountFilterAssembler(
// History: older gift wraps, loaded on demand in bounded one-shot slices.
val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys)
// Live tail: the recent week of notifications from the inbox + group host relays.
val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys)
// History: older notifications, paged backward by until+limit per relay, driven by the feed's markers.
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys)
val group =
listOf(
AccountMetadataEoseManager(client, ::allKeys),
giftWraps,
giftWrapsHistory,
AccountDraftsEoseManager(client, ::allKeys),
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
notifications,
notificationsHistory,
MarmotGroupEventsEoseManager(client, ::allKeys),
)

View File

@@ -0,0 +1,222 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
/**
* Loads the account's notification **history** — everything older than the one-week live tail
* ([AccountNotificationsEoseFromInboxRelaysManager]) — by **`until`+`limit` paging, per relay, on
* demand**, so the notifications feed can be scrolled back in time instead of being pinned to the
* recent week.
*
* There is no proactive walk: each relay advances exactly one page when the feed's on-screen
* window-limit marker for that relay asks ([advance]), then **parks** at its window limit. The markers
* are the drivers — a relay pages only while its marker is visible, and keeps paging as long as it
* stays visible (see the notifications card feed). So a spam-dense relay never floods: the user has to
* scroll through its notifications to pull more, and nothing is fetched while its marker is off screen.
*
* Relays paged: the same set the live inbox loader covers — the user's inbox relays (all notification
* kinds tagging me) plus each joined NIP-29 group's host relay (group-activity kinds scoped by `#h`).
* The foreground "random follows" straggler query ([AccountNotificationsEoseFromRandomRelaysManager],
* tiny latest-N limits) is deliberately live-tail only and is NOT paged here.
*
* The per-relay cursors live on the [Account] (so they share the account's lifetime); this class binds
* the single-active [BackwardRelayPager] orchestrator to them on [newSub], builds the notification REQ
* filters, and forwards relay callbacks into the pager. A relay is *done* once it answers an empty page;
* one that won't answer (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted]
* flips once every relay is either done or stalled.
*/
class AccountNotificationsHistoryEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
// A modest page: each marker-triggered advance pulls ~500 older notifications, digestible to render
// and enough to fill a scroll, rather than the gift-wrap default (a whole encrypted-blob band at once).
private val pager = BackwardRelayPager("notifications.history", pageLimit = 500)
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
// Each joined group's id, bucketed by the normalized host relay it lives on. Used both to route the
// group filter and (its keys) to add group host relays to the paged relay set.
private fun groupsByRelay(account: Account): Map<NormalizedRelayUrl, List<String>> =
account.relayGroupList.liveRelayGroupList.value
.groupBy({ RelayUrlNormalizer.normalizeOrNull(it.relayUrl) }, { it.groupId })
.mapNotNull { (relay, ids) -> relay?.let { it to ids.distinct() } }
.toMap()
// The full relay set this account pages notifications back through: inbox relays + group host relays.
private fun notificationRelaySet(account: Account): Set<NormalizedRelayUrl> = account.notificationRelays.flow.value + groupsByRelay(account).keys
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (!key.account.isWriteable()) return emptyList()
val pubkey = user(key).pubkeyHex
val inbox = key.account.notificationRelays.flow.value
val groups = groupsByRelay(key.account)
// Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a
// page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't
// re-REQ it — it stays parked until the marker advances it again.
val armed = pager.armedRelays(inbox + groups.keys)
if (armed.isEmpty()) return emptyList()
return armed.flatMap { relay ->
val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList()
Log.d(TAG) { "[notifications.history] REQ ${relay.url} until=$until limit=${pager.pageLimit}" }
buildList {
if (relay in inbox) {
addAll(filterNotificationsHistoryToPubkey(relay, pubkey, until, pager.pageLimit))
}
groups[relay]?.let { groupIds ->
addAll(filterGroupNotificationsHistoryToPubkey(relay, pubkey, groupIds, until, pager.pageLimit))
}
}
}
}
/** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */
fun advance(relay: NormalizedRelayUrl) {
if (pager.advance(relay)) invalidateFilters()
}
/** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */
fun advanceAll() {
if (pager.advanceAll()) {
Log.d(TAG) { "[notifications.history] advanceAll (empty-feed bootstrap)" }
invalidateFilters()
}
}
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
// Repoint the single-active orchestrator at this account's notification cursors and the relay set
// it fans out to, refreshing the display flows from the restored progress.
pager.bind(key.account.notificationHistory, key.account.scope) { notificationRelaySet(key.account) }
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
// A relay joining/leaving the paged set (inbox change, group join/leave) re-issues the REQ
// so a newly-added relay can be armed and a removed one drops out.
key.account.scope.launch(Dispatchers.IO) {
key.account.notificationRelays.flow
.sample(1000)
.collectLatest { invalidateFilters() }
},
key.account.scope.launch(Dispatchers.IO) {
key.account.relayGroupList.liveRelayGroupList
.sample(1000)
.collectLatest { invalidateFilters() }
},
)
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: AccountQueryState): SubscriptionListener {
// A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to
// another account; gate the pager (single-active) on whether it's still bound to THIS account's
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.notificationHistory
return object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
Log.d(TAG) { "[notifications.history] ${relay.url} reached the bottom (done)" }
}
// No auto-advance: the relay parks here until its marker asks for the next page.
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
companion object {
private const val TAG = "NotificationPagination"
}
}

View File

@@ -124,6 +124,70 @@ val NotificationsPerKeyKinds3 =
AttestorRecommendationEvent.KIND,
)
/**
* Every kind the notifications feed cares about on the user's inbox relays, flattened into one list.
* The live-tail query ([filterNotificationsToPubkey] / [filterSummaryNotificationsToPubkey]) splits these
* across several filters with different per-kind limits; backward history paging instead asks ONE filter
* per relay so the single until+limit cursor stays gap-proof (an empty page truly means "nothing older").
*/
val AllNotificationKinds =
(SummaryKinds + NotificationsPerKeyKinds + NotificationsPerKeyKinds2 + NotificationsPerKeyKinds3).distinct()
/**
* One backward-paging page of notifications on an inbox relay: the N newest events tagging me
* ([AllNotificationKinds], `#p` = me) strictly older than [until]. A single filter (not the live query's
* split) so the [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager]
* cursor tracking the oldest delivered `created_at` can't skip a band that a per-kind sub-limit capped.
*/
fun filterNotificationsHistoryToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = AllNotificationKinds,
tags = mapOf("p" to listOf(pubkey)),
limit = limit,
until = until,
),
),
)
}
/**
* One backward-paging page of NIP-29 group-activity notifications on a group's host relay:
* [GroupNotificationKinds] tagging me (`#p`) inside my joined groups (`#h`), strictly older than [until].
*/
fun filterGroupNotificationsHistoryToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
groupIds: List<String>,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty() || groupIds.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = GroupNotificationKinds,
tags = mapOf("p" to listOf(pubkey), "h" to groupIds),
limit = limit,
until = until,
),
),
)
}
fun filterSummaryNotificationsToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,

View File

@@ -53,6 +53,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
@@ -70,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.note.NutzapUserSetCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.donations.ShowDonationCard
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -89,9 +93,21 @@ fun RenderCardFeed(
routeForLastRead: String,
scrollToEventId: String? = null,
headerContent: (@Composable () -> Unit)? = null,
// Whether THIS feed drives the shared account history pager. False for an off-screen split tab / second
// pane, so only the feed the user is actually looking at pages the pager (see #2 in the audit).
drivesPaging: Boolean = true,
) {
val feedState by feedContent.feedContent.collectAsStateWithLifecycle()
// A genuinely empty feed has no rows for the look-ahead buffer driver to measure, so step every relay
// one page at a time to hunt for the first notifications. Once cards appear the buffer driver takes
// over. Gated on Empty only (never the transient Loading navigation flashes through), and only for the
// active feed so an off-screen tab doesn't hunt on the shared pager.
val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory }
if (drivesPaging) {
BootstrapNotificationHistoryWhenEmpty(feedState is CardFeedState.Empty, history.loadingMore, history.status) { history.advanceAll() }
}
// Direct switch instead of CrossfadeIfEnabled: the crossfade's `currentlyVisible`
// accumulator can leave a previous `Loaded` instance composed alongside the new
// one when refreshes (e.g. double-tap on the Notifications tab) bounce the
@@ -116,6 +132,7 @@ fun RenderCardFeed(
nav = nav,
scrollToEventId = scrollToEventId,
headerContent = headerContent,
drivesPaging = drivesPaging,
)
}
@@ -149,10 +166,30 @@ private fun FeedLoaded(
nav: INav,
scrollToEventId: String? = null,
headerContent: (@Composable () -> Unit)? = null,
drivesPaging: Boolean = true,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val openPolls by polls.flow.collectAsStateWithLifecycle()
// Infinite-scroll backward pagination of the notifications history. All the driving (look-ahead buffer,
// auto-retry, per-relay sentinels) lives in [rememberNotificationHistoryPaging]; here we just get back
// the per-relay cursors to draw as frontier markers, and keep [history] for the detail dialog's retry.
val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory }
// Count of items above the notification cards in the LazyColumn (scaffold header + donation card + open
// polls), so the hoisted sentinel can map a visible LazyColumn index back to a card.
val leadingItemCount = (if (headerContent != null) 1 else 0) + 1 + openPolls.size
val limits =
rememberNotificationHistoryPaging(history, listState, drivesPaging) { index ->
items.list.getOrNull(index - leadingItemCount)?.createdAt()
}
// The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup.
var syncDetail by remember { mutableStateOf<List<RelayReachCursor>?>(null) }
syncDetail?.let { detail ->
// Tap-through offers a Try Again on stalled relays, so a user who sees a bad relay can act on it.
RelayReachDetailDialog(detail, ::formatHistoryReachDate, onRetry = { history.advanceAll() }) { syncDetail = null }
}
StickToTopOnPrepend(listState, items.list.firstOrNull()?.id())
// Track which card is highlighted (will auto-clear after animation)
@@ -233,7 +270,7 @@ private fun FeedLoaded(
items = items.list,
key = { _, item -> item.id() },
contentType = { _, item -> item.javaClass.simpleName },
) { _, item ->
) { index, item ->
val isHighlighted = highlightedCardId == item.id()
val highlightColor by animateColorAsState(
targetValue = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent,
@@ -257,6 +294,18 @@ private fun FeedLoaded(
HorizontalDivider(
thickness = DividerThickness,
)
// Per-relay markers in the gap toward the next-older card, at the depth each relay has paged to.
// The bulk load is buffer-driven (above); these mark each relay's frontier and drive the
// stalled-relay retry when scrolled into view (see the sentinel above). olderCreatedAt is null
// past the oldest loaded card, so relays that reached the bottom sit there as "fully loaded".
if (limits.isNotEmpty()) {
RelayReachMarkers(
limits,
item.createdAt(),
items.list.getOrNull(index + 1)?.createdAt(),
) { syncDetail = it }
}
}
}
}

View File

@@ -0,0 +1,202 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
/**
* Drives the notifications feed's infinite-scroll backward pagination over the account's per-relay
* [AccountNotificationsHistoryEoseManager] (a [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager]:
* each relay keeps its own until+limit cursor so faulty relays with different datasets page independently
* and can't gap each other) and returns the per-relay [RelayReachCursor]s the feed draws as frontier
* markers.
*
* Three cooperating drivers, all gated on [drivesPaging] so only the on-screen feed pages the shared
* account pager (an off-screen split tab / second pane stays idle):
* 1. **Look-ahead buffer** — keeps a fat runway of older notifications loaded ahead of the viewport, so
* healthy relays fill the feed and the user practically never reaches the end. Bounded per burst
* ([NOTIFICATION_MAX_PAGES_PER_BURST]) and reset by scrolling, so a dense account whose events collapse
* into few cards can't burst-download its whole history to hit the row target, while a normal account
* still preloads the full look-ahead from the top.
* 2. **Auto-retry** — when every relay is done-or-stalled (exhausted) but some are merely stalled (a
* slow/unreachable relay, not a real end), re-advances them on a backoff so recovery doesn't depend on
* the user scrolling to the marker or reopening.
* 3. **Per-relay sentinels** — retry an individual relay the moment its frontier marker scrolls into view;
* the buffer keeps the frontier below the fold, so these stay quiet unless the buffer can't keep up.
*
* @param createdAtAt createdAt of the card at a LazyColumn index (null past the ends / non-card rows), so
* the hoisted sentinel can test which inter-card gap a relay's cursor sits in against the visible rows.
*/
@Composable
fun rememberNotificationHistoryPaging(
history: AccountNotificationsHistoryEoseManager,
listState: LazyListState,
drivesPaging: Boolean,
createdAtAt: (index: Int) -> Long?,
): List<RelayReachCursor> {
val historyStatus by history.status.collectAsStateWithLifecycle()
val exhausted = historyStatus.exhausted
val loadingMore by history.loadingMore.collectAsStateWithLifecycle()
// Keep a big runway of already-loaded rows below the fold so the user effectively never reaches the end.
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleIndex =
listState.layoutInfo.visibleItemsInfo
.lastOrNull()
?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
totalItems > 0 && lastVisibleIndex >= totalItems - NOTIFICATION_LOOKAHEAD_BUFFER
}
}
// Bound the eager fill. Pages are pulled in events but the buffer is counted in rows, and notifications
// collapse heavily into cards — so on a dense account a page can add very few rows, and an uncapped fill
// would keep pulling until it downloaded the whole history to reach the row target. Cap the consecutive
// pages pulled WITHOUT the user scrolling; scrolling (firstVisibleItemIndex moving) resets the budget so
// paging resumes as the buffer is consumed. From position 0 this still preloads the full look-ahead for a
// normal account (12 pages), yet a dense whale can't burst-download everything on open.
val firstVisibleIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } }
var pagesThisBurst by remember { mutableIntStateOf(0) }
LaunchedEffect(firstVisibleIndex) { pagesThisBurst = 0 }
// Re-evaluated when the buffer runs low, a page settles (loadingMore falls), paging exhausts, this feed
// (de)activates, or the burst budget changes — so a page that doesn't refill the buffer keeps pulling the
// next (up to the burst cap) until the buffer is full or relays run dry.
LaunchedEffect(drivesPaging, shouldLoadMore, loadingMore, exhausted, pagesThisBurst) {
if (drivesPaging && shouldLoadMore && !loadingMore && !exhausted && pagesThisBurst < NOTIFICATION_MAX_PAGES_PER_BURST) {
history.advanceAll()
pagesThisBurst++
}
}
// Auto-retry faulty relays with backoff, only while this feed drives paging. A single non-restarting
// loop so the backoff survives the transient in-flight blips each retry causes.
LaunchedEffect(history, drivesPaging) {
if (!drivesPaging) return@LaunchedEffect
var backoffMs = STALLED_RETRY_MIN_MS
while (true) {
history.status.first { it.exhausted && it.stalledCount > 0 } // park until stuck on a stalled relay
while (true) {
delay(backoffMs)
val s = history.status.value
if (!(s.exhausted && s.stalledCount > 0)) break // recovered (a relay answered, or scroll retried)
history.advanceAll()
history.loadingMore.first { !it } // let the retry settle before escalating
backoffMs = (backoffMs * 2).coerceAtMost(STALLED_RETRY_MAX_MS)
}
backoffMs = STALLED_RETRY_MIN_MS // reset for the next stall
}
}
// One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls
// its next page. A done relay's marker sinks to the oldest end reading "fully loaded".
val limits =
remember(historyStatus) {
historyStatus.relayProgress.map { (relay, p) ->
RelayReachCursor(relay.url, relayShortName(relay), p.reachedUntil, reachState(p)) { history.advance(relay) }
}
}
// Per-relay retry driver: when a relay's frontier marker is on screen (the buffer couldn't keep the
// frontier ahead, i.e. that relay stalled or the feed is genuinely at its end), step that one relay.
if (drivesPaging && limits.isNotEmpty()) {
RelayReachSentinels(limits, listState, createdAtAt)
}
return limits
}
/**
* Bootstraps notification history while the feed is genuinely empty: steps every relay one page at a
* time, gated on its own loader, until notifications appear or every relay exhausts. Once cards load this
* stops and the look-ahead buffer driver takes over, keeping older pages loaded ahead of the viewport.
*
* Leads with a debounce so the brief Empty/Loading flash navigation passes through does NOT trigger a
* hunt; if [active] drops before it elapses (cards loaded) the effect cancels and nothing pages.
*/
@Composable
fun BootstrapNotificationHistoryWhenEmpty(
active: Boolean,
loadingMore: StateFlow<Boolean>,
status: StateFlow<PagingStatus>,
advanceAll: () -> Unit,
) {
LaunchedEffect(active, loadingMore, status) {
if (!active) return@LaunchedEffect
delay(BOOTSTRAP_DEBOUNCE_MS)
combine(loadingMore, status) { loading, s -> !loading && !s.exhausted }
.distinctUntilChanged()
.filter { it }
.collect { advanceAll() }
}
}
// Ignore the transient empty feed that navigation flashes through before notifications re-appear.
private const val BOOTSTRAP_DEBOUNCE_MS = 1200L
// How many already-loaded rows to keep below the last visible one before pulling the next older page.
// Large on purpose: the feed reads as infinite scroll, the user practically never reaches the bottom.
private const val NOTIFICATION_LOOKAHEAD_BUFFER = 100
// Cap on consecutive pages pulled to fill the buffer WITHOUT the user scrolling (the budget resets on
// scroll). Generous so a normal account preloads the full look-ahead from the top in 12 pages, while a
// dense account whose events collapse into few cards is bounded instead of burst-downloading everything.
private const val NOTIFICATION_MAX_PAGES_PER_BURST = 6
// Backoff bounds for auto-retrying stalled (slow/unreachable) relays: first retry ~3s after a stall,
// doubling up to ~30s, so a faulty relay is retried gently but keeps a chance to recover on its own.
private const val STALLED_RETRY_MIN_MS = 3_000L
private const val STALLED_RETRY_MAX_MS = 30_000L
private fun reachState(p: RelayPagingProgress): RelayReachState =
when {
p.done -> RelayReachState.DONE
p.stalled -> RelayReachState.STALLED
else -> RelayReachState.REACHING
}
private fun relayShortName(relay: NormalizedRelayUrl): String =
relay.url
.substringAfter("://")
.trimEnd('/')
.substringBefore('/')

View File

@@ -258,6 +258,9 @@ private fun SplitNotificationsBody(
nav: INav,
) {
HorizontalPager(state = pagerState) { page ->
// Only the settled, on-screen tab drives the shared account history pager, so the off-screen tab
// (composed during a swipe) doesn't page notifications the user isn't looking at.
val drivesPaging = page == pagerState.currentPage
when (page) {
0 -> {
NotificationPagerPage(
@@ -265,6 +268,7 @@ private fun SplitNotificationsBody(
pollContent = notifPolls,
scrollStateKey = ScrollStateKeys.NOTIFICATION_FOLLOWING,
scrollToEventId = scrollToEventId,
drivesPaging = drivesPaging,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -278,6 +282,7 @@ private fun SplitNotificationsBody(
// Only the Following tab honors the deep-link scroll target so users
// aren't bounced when they swipe across to Everyone.
scrollToEventId = null,
drivesPaging = drivesPaging,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -294,6 +299,7 @@ private fun NotificationPagerPage(
scrollToEventId: String?,
accountViewModel: AccountViewModel,
nav: INav,
drivesPaging: Boolean = true,
) {
RefresheableBox(state, true) {
val listState = rememberForeverLazyListState(scrollStateKey)
@@ -309,6 +315,7 @@ private fun NotificationPagerPage(
routeForLastRead = NOTIFICATION_LAST_READ_KEY,
scrollToEventId = scrollToEventId,
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) },
drivesPaging = drivesPaging,
)
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the backward-paging notification history filters: they must ask for the N newest events strictly
* OLDER than a cursor (`until`+`limit`, no `since`), so the single per-relay cursor the
* [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager] tracks
* can't skip a band and an empty page truly means "nothing older" (see RelayLoadingCursors).
*/
class FilterNotificationsHistoryTest {
private val relay = RelayUrlNormalizer.normalize("wss://inbox.example.com")
private val pubkey = "aa".repeat(32)
private val until = 1_700_000_000L
@Test
fun `history filter asks one until+limit page tagging me, no since`() {
val filters = filterNotificationsHistoryToPubkey(relay, pubkey, until, 500)
// A single combined-kinds filter, not the live query's split — one cursor stays gap-proof.
assertEquals(1, filters.size)
val f = filters.first().filter
assertEquals(relay, filters.first().relay)
assertEquals(until, f.until)
assertEquals(500, f.limit)
assertNull("history pages by until, never since", f.since)
assertEquals(listOf(pubkey), f.tags?.get("p"))
assertEquals(AllNotificationKinds, f.kinds)
}
@Test
fun `combined kinds cover every live-query notification kind`() {
listOf(SummaryKinds, NotificationsPerKeyKinds, NotificationsPerKeyKinds2, NotificationsPerKeyKinds3)
.flatten()
.forEach { kind ->
assertTrue("AllNotificationKinds must include live kind $kind", kind in AllNotificationKinds)
}
// Flattened + de-duplicated: no kind appears twice.
assertEquals(AllNotificationKinds.size, AllNotificationKinds.toSet().size)
}
@Test
fun `group history filter scopes to my groups by h tag`() {
val groupIds = listOf("group-a", "group-b")
val filters = filterGroupNotificationsHistoryToPubkey(relay, pubkey, groupIds, until, 500)
assertEquals(1, filters.size)
val f = filters.first().filter
assertEquals(until, f.until)
assertNull(f.since)
assertEquals(listOf(pubkey), f.tags?.get("p"))
assertEquals(groupIds, f.tags?.get("h"))
assertEquals(GroupNotificationKinds, f.kinds)
}
@Test
fun `empty pubkey or groups yields no filter`() {
assertTrue(filterNotificationsHistoryToPubkey(relay, null, until, 500).isEmpty())
assertTrue(filterNotificationsHistoryToPubkey(relay, "", until, 500).isEmpty())
assertTrue(filterGroupNotificationsHistoryToPubkey(relay, pubkey, emptyList(), until, 500).isEmpty())
}
}

View File

@@ -307,14 +307,63 @@ default search relays.
### Git (NIP-34)
nak's `clone`/`push`/`pull` (git-packfile transport over relays/GRASP) are out of scope — these are the metadata + collaboration events.
`amy git` mirrors the pure-Nostr surface of [`ngit`](https://github.com/DanConwayDev/ngit-cli)
and `nak git`: repository announcements + state, patches, pull requests, issues,
threaded NIP-22 comments, and status updates. The git **packfile** transport
(`clone`/`fetch`/`push` of real git objects to clone/GRASP servers) is out of
scope — that needs a git plumbing layer, not an event builder — so `amy git`
publishes and reads the collaboration events, and you clone/push with `git`
itself (or `ngit`).
Every write verb accepts `[--relay URL[,URL]]` to override the target relays;
the default is the repo's advertised relays, else your outbox.
**Repository**
| Command | What it does |
|---|---|
| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--d ID]` | Publish a kind:30617 repository announcement. |
| `amy git init [--name N] [--description D] [--clone URL[,URL]] [--relay URL[,URL]] [--no-state] [--repo PATH] [--d ID]` | Bootstrap a repo from the local git checkout (like `ngit init`): derive name, clone URL, earliest-unique-commit, and branch/tag state via `git`, then publish the kind:30617 announcement and (unless `--no-state`) the kind:30618 state. Any flag overrides a derived value. |
| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--personal-fork] [--d ID]` | Publish a kind:30617 repository announcement (manual, no local repo needed). |
| `amy git state REPO\|IDENTIFIER [--head BRANCH] [--branch name=commit[,…]] [--tag name=commit[,…]]` | Publish a kind:30618 repository state (branch/tag tips + HEAD). |
| `amy git list [USER]` | List a user's repo announcements (defaults to self). |
| `amy git show NADDR\|kind:pubkey:id` | Print one repo announcement (cache-first). |
| `amy git issue NADDR\|coords --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue against a repo. BODY from arg or stdin. |
| `amy git grasp list [USER]` | List a user's kind:10317 GRASP hosting-server list (defaults to self). |
| `amy git grasp set URL[,URL]` | Publish your GRASP hosting-server list (preference order — where PR tips get pushed). |
**Read repository content** (git smart-HTTP v2, read-only — needs a reachable git host)
`REPO` here is a repo coordinate/naddr (whose announcement supplies the clone
URL) **or** a raw `http(s)` clone URL. This is the git-object read side of
`nak git download` / a shallow clone; pushing objects back is out of scope.
| Command | What it does |
|---|---|
| `amy git browse REPO [PATH] [--ref R] [--clone URL]` | List a repo's tree entries at PATH (default: root). |
| `amy git cat REPO PATH [--ref R] [--out FILE]` | Print a file's contents at a ref (or write raw bytes to `--out`). |
| `amy git log REPO [--ref R] [--depth N] [--clone URL]` | Recent commit history, most recent first. |
**Issues, patches & pull requests**
| Command | What it does |
|---|---|
| `amy git issue REPO --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue. BODY from arg or stdin. |
| `amy git patch REPO [--file PATH] [--root\|--root-revision] [--commit C] [--parent-commit P] [--in-reply-to ID]` | Publish a kind:1617 patch. Body is `git format-patch` output from `--file` or stdin. |
| `amy git apply PATCH_ID [--check\|--print] [--repo PATH]` | Fetch a kind:1617 patch and apply it to the local working tree (`git am`); `--check` dry-runs, `--print` emits the patch. |
| `amy git pr REPO --commit TIP --clone URL[,URL] [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]] [DESC]` | Publish a kind:1618 pull request (references a pushed branch tip by clone URL + commit). |
| `amy git pr-update PR --commit TIP --clone URL[,URL] [--merge-base C]` | Publish a kind:1619 update to a pull request's tip. |
| `amy git issues\|patches\|prs REPO [--open\|--applied\|--closed\|--draft\|--status a,b] [--limit N]` | List a repo's issues / patches / PRs with their derived status. |
| `amy git thread EVENT_ID` | Print one item plus its status timeline and comments. |
**Comments & status**
| Command | What it does |
|---|---|
| `amy git comment TARGET [BODY]` | Reply to an issue/patch/PR/repo with a NIP-22 kind:1111 comment. BODY from arg or stdin. |
| `amy git label TARGET LABEL[,LABEL] [--namespace N]` | Attach NIP-32 kind:1985 labels to an issue/patch/PR (namespace defaults to `ugc`). |
| `amy git open TARGET [MSG]` | Publish a kind:1630 status (open / reopen / ready-for-review). |
| `amy git applied TARGET [MSG] [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]]` | Publish a kind:1631 status (applied / merged / resolved). Aliases: `merged`, `resolved`. |
| `amy git close TARGET [MSG]` | Publish a kind:1632 status (closed). |
| `amy git draft TARGET [MSG]` | Publish a kind:1633 status (draft). |
### Podcasts (NIP-F4)

View File

@@ -109,7 +109,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
| `nip` | `amy nip` | ✅ | repo-first lookup + Nostr fallback (NipText kind:30817, wiki:30818, long-form:30023); `nip list`. |
| `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP) covering **every** event kind quartz defines (280 entries); number lookup + name search. |
| `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). |
| `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. |
| `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), NIP-32 labels (1985), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `apply` applies a fetched patch to the local tree (`git am`); `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) and NIP-34 cover notes (1624, no quartz builder yet) are out of scope. Event tag shapes were verified byte-for-byte against the ngit reference implementation and the NIP-34 spec (`clone`/`web` as single multi-value tags, issue `p`-tag for maintainer routing, plain patch/PR `r` tags); the quartz readers stay tolerant of the legacy repeated form. See `quartz/…/nip34Git/GitNip34InteropTest`. |
| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. |
| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. |
| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. |
@@ -129,8 +129,10 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage:
Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, `encode`/`decode`,
`admin`) are interop-verified against the real `nak` binary or `amy serve`.
- **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`),
`git` (NIP-34 events only — no packfile transport), `outbox` (NIP-65 model vs
nak's local hints DB).
`git` (full NIP-34 event surface — announce/state/patch/PR/issue/comment/status
+ status-deriving reads + GRASP list, plus `browse`/`cat`/`log` reading git
objects over smart-HTTP; only packfile **push** is out of scope), `outbox`
(NIP-65 model vs nak's local hints DB).
- **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE),
`spell` (MuSig2/FROST), and `validate` (event-schema validation).
@@ -173,10 +175,11 @@ move anything, re-audit — you're probably duplicating logic.
8. **Distribution** — Homebrew + Scoop + `.deb` in the same release
pipeline as desktop. Plan: `cli/plans/2026-04-21-cli-distribution.md`.
9. **Test suite** — largely in place, two layers:
- **Shell harnesses** under `cli/tests/`nine suites: `blossom`
(live servers), `cache`, `clink`, `dm`, `marmot` (vs whitenoise-rs),
`nests` (manual audio-rooms matrix), `pow`, `relaygroup`, `sync`,
plus the shared `headless/` helpers. See `cli/tests/README.md`.
- **Shell harnesses** under `cli/tests/`ten suites: `blossom`
(live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`),
`marmot` (vs whitenoise-rs), `nests` (manual audio-rooms matrix), `pow`,
`relaygroup`, `sync`, plus the shared `headless/` helpers. See
`cli/tests/README.md`.
None run in CI yet (the relay-backed ones need Rust + a ~3 min
cold `nostr-rs-relay` build).
- **JVM unit suite** at `cli/src/test/kotlin/``Args` parsing,

View File

@@ -624,14 +624,34 @@ private fun printUsage() {
| blossom mirror --server URL SOURCE-URL ask the server to mirror a blob (BUD-04)
|
|Git (NIP-34):
| git init [--name N] [--clone URL] bootstrap a repo from the local git checkout
| [--no-state] [--repo PATH] (derives fields via `git`; publishes 30617+30618)
| git announce --name N [--description D] publish a kind:30617 repo announcement
| [--clone URL[,URL]] [--web URL[,URL]] (--d sets the identifier; defaults to name)
| [--relay URL[,URL]] [--maintainer HEX[,]]
| [--hashtag T[,T]] [--earliest-commit C] [--d ID]
| git state REPO [--head B] [--branch n=c[,…]] publish a kind:30618 repository state
| [--tag n=c[,…]]
| git list [USER] list a user's repo announcements (default self)
| git show NADDR|kind:pubkey:id print one repo announcement
| git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo
| [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin)
| git grasp list [USER] | set URL[,URL] GRASP hosting-server list (kind 10317)
| git browse REPO [PATH] | cat REPO PATH read repo tree/file over git smart-HTTP
| git log REPO [--depth N] recent commit history (read-only)
| git issue REPO --subject S [BODY] publish a kind:1621 issue against a repo
| [--hashtag T[,T]] (BODY from arg or stdin)
| git patch REPO [--file P] [--root] publish a kind:1617 patch (format-patch/stdin)
| [--commit C] [--parent-commit P] [--in-reply-to ID]
| git pr REPO --commit TIP --clone URL[,URL] publish a kind:1618 pull request [DESC]
| [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]]
| git pr-update PR --commit TIP --clone URL publish a kind:1619 pull-request update
| git comment TARGET [BODY] NIP-22 kind:1111 comment on issue/patch/PR/repo
| git label TARGET LABEL[,LABEL] NIP-32 kind:1985 labels on an issue/patch/PR
| git apply PATCH_ID [--check|--print] apply a fetched kind:1617 patch to the local tree
| git open|applied|close|draft TARGET [MSG] publish a kind:1630/1631/1632/1633 status
| git issues|patches|prs REPO list a repo's issues/patches/PRs + status
| [--open|--applied|--closed|--draft] [--limit N]
| git thread EVENT_ID print an item + status timeline + comments
| (git-packfile clone/push transport is out of scope — see cli/ROADMAP.md)
|
|Podcasts (NIP-F4):
| podcast metadata --title T --image URL publish kind:10154 show metadata

View File

@@ -0,0 +1,145 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/**
* `amy git apply PATCH_ID` — fetch a NIP-34 kind:1617 patch and apply it to the
* local git working tree (the `nak git patch apply` / `ngit pr apply` surface).
* The patch content is `git format-patch` output, so by default it is fed to
* `git am` (applied as a commit); `--check` dry-runs `git apply --check` and
* `--print` just emits the patch without touching the tree.
*
* This shells out to `git`, like `git init` — it operates on the local checkout.
*/
object GitApplyCommand {
/** Safety ceiling for the local `git am`/`git apply` invocation so a wedged git can't hang the CLI. */
private const val GIT_TIMEOUT_SEC = 60L
suspend fun apply(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val patchRef = args.positional(0, "patch-event-id")
val repoDir = File(args.flag("repo") ?: ".").absoluteFile
val check = args.bool("check")
val print = args.bool("print")
val id =
GitSupport.resolveEventId(patchRef)
?: return Output.error("bad_args", "expected a note/nevent/64-hex patch id")
args.rejectUnknown("relay")
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val patch =
GitSupport.fetchEvent(ctx, id, args) as? GitPatchEvent
?: return Output.error("not_found", "no patch (kind 1617) found for $patchRef")
val content = patch.content
val subject = patch.subject()
if (print) {
Output.emit(mapOf("patch_id" to patch.id, "subject" to subject, "content" to content))
return 0
}
val (mode, gitArgs) =
if (check) {
"check" to arrayOf("apply", "--check", "-")
} else {
"am" to arrayOf("am", "--")
}
val (code, output) = runGit(repoDir, content, *gitArgs)
if (code != 0) {
// Leave the tree clean on a failed `git am` so a retry isn't blocked.
if (mode == "am") runGit(repoDir, null, "am", "--abort")
return Output.error(
"apply_failed",
"git $mode failed for patch ${patch.id}",
extra = mapOf("patch_id" to patch.id, "mode" to mode, "git_output" to output.trim()),
)
}
Output.emit(
mapOf(
"patch_id" to patch.id,
"subject" to subject,
"mode" to mode,
"applied" to (mode == "am"),
"git_output" to output.trim(),
),
)
return 0
}
}
/** Run `git <args>` in [repoDir], optionally feeding [input] on stdin; returns (exitCode, merged stdout+stderr). */
private fun runGit(
repoDir: File,
input: String?,
vararg gitArgs: String,
): Pair<Int, String> {
var writer: Thread? = null
var reader: Thread? = null
return try {
val proc =
ProcessBuilder(listOf("git", *gitArgs))
.directory(repoDir)
.redirectErrorStream(true)
.start()
// Feed stdin AND drain stdout on side threads: a large patch (bigger than
// the OS pipe buffer) would otherwise deadlock, and reading on this thread
// would block an unbounded wait. The patch was decoded as UTF-8, so write
// it back as UTF-8 (not the JVM default charset, which would corrupt
// non-ASCII filenames/messages).
writer =
if (input != null) {
thread(name = "git-stdin") { runCatching { proc.outputStream.use { it.write(input.toByteArray(Charsets.UTF_8)) } } }
} else {
proc.outputStream.close()
null
}
val sb = StringBuilder()
reader = thread(name = "git-out") { runCatching { sb.append(proc.inputStream.readBytes().decodeToString()) } }
if (!proc.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)) {
proc.destroyForcibly()
124 to "git timed out after ${GIT_TIMEOUT_SEC}s"
} else {
reader.join()
proc.exitValue() to sb.toString()
}
} catch (e: Exception) {
1 to (e.message ?: "could not run git (is it installed and is this a git repo?)")
} finally {
// Always join so the non-daemon side threads can't outlive the call (e.g.
// under the in-process test harness, which doesn't exitProcess).
writer?.join(1_000)
reader?.join(1_000)
}
}
}

View File

@@ -0,0 +1,253 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry
import java.io.File
/**
* `amy git browse|cat|log` — read a repository's actual git content over the
* git smart-HTTP v2 protocol (the same [GitHttpClient] the Android repo browser
* uses). Resolves the http(s) clone URL from the kind:30617 announcement and
* does a shallow fetch, so this is read-only and needs a reachable git host.
* This is the git-object read side of `nak git download` / a shallow `clone`;
* pushing objects back is still out of scope (see cli/ROADMAP.md).
*/
object GitBrowseCommands {
/** `git browse REPO [PATH]` — list the tree entries at PATH (default: repo root). */
suspend fun browse(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val path = args.positionalOrNull(1).orEmpty()
val ref = args.flag("ref")
val cloneOverride = args.flag("clone")
args.rejectUnknown("relay")
return withSnapshot(dataDir, coord, ref, cloneOverride, args) { _, snapshot ->
val segments = splitPath(path)
val entries =
if (segments.isEmpty()) {
snapshot.rootEntries()
} else {
snapshot.entriesAt(segments)
?: return@withSnapshot Output.error("not_found", "no directory at path '$path'")
}
Output.emit(
mapOf(
"clone_url" to snapshot.cloneUrl,
"branch" to snapshot.branch,
"head_commit" to snapshot.headCommit,
"path" to path,
"count" to entries.size,
"entries" to entries.map(::entrySummary),
),
)
0
}
}
/** `git cat REPO PATH` — print (or `--out FILE`) a file's contents at a ref. */
suspend fun cat(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val path = args.positional(1, "file-path")
val ref = args.flag("ref")
val cloneOverride = args.flag("clone")
val out = args.flag("out")
args.rejectUnknown("relay")
return withSnapshot(dataDir, coord, ref, cloneOverride, args) { _, snapshot ->
val entry =
snapshot.entryAt(splitPath(path))
?: return@withSnapshot Output.error("not_found", "no file at path '$path'")
if (entry.isFolder) return@withSnapshot Output.error("bad_args", "'$path' is a directory (use `git browse`)")
val bytes = snapshot.readBlob(entry.oid)
val binary = isBinary(bytes)
if (out != null) {
File(out).writeBytes(bytes)
}
Output.emit(
mapOf(
"clone_url" to snapshot.cloneUrl,
"path" to path,
"oid" to entry.oid,
"size_bytes" to bytes.size,
"binary" to binary,
"written_to" to out,
// Text content is inlined only when it isn't binary and wasn't written to a file.
"content" to if (!binary && out == null) bytes.decodeToString() else null,
),
)
0
}
}
/** `git log REPO` — recent commit history (most recent first). */
suspend fun log(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val ref = args.flag("ref")
val cloneOverride = args.flag("clone")
val depth = args.intFlag("depth", 50)
args.rejectUnknown("relay")
return withSnapshot(dataDir, coord, ref, cloneOverride, args) { http, snapshot ->
val commits = http.loadHistory(snapshot.cloneUrl, snapshot.headCommit, depth)
Output.emit(
mapOf(
"clone_url" to snapshot.cloneUrl,
"branch" to snapshot.branch,
"count" to commits.size,
"commits" to
commits.map {
mapOf(
"oid" to it.oid,
"short_oid" to it.shortOid,
"summary" to it.summary,
"author" to it.authorName,
"author_email" to it.authorEmail,
"author_time" to it.authorTimeSec,
"parents" to it.parents,
)
},
),
)
0
}
}
// ------------------------------------------------------------------
/**
* Resolve the repo announcement, pick its http(s) clone URLs, open a shallow
* snapshot (trying each candidate URL), and hand the client + snapshot to
* [block]. Emits a `not_found` / `unreachable` error when the repo or a
* working clone URL can't be resolved.
*/
private suspend fun withSnapshot(
dataDir: DataDir,
coord: String,
ref: String?,
cloneOverride: String?,
args: Args,
block: suspend (GitHttpClient, GitRepoSnapshot) -> Int,
): Int {
// REPO may be a raw http(s) clone URL, an naddr, or `kind:pubkey:id`.
val directUrl = coord.takeIf { it.startsWith("http://") || it.startsWith("https://") }
val addr =
if (directUrl == null) {
GitSupport.resolveAddress(coord)
?: return Output.error("bad_args", "expected a clone URL, an naddr, or kind:pubkey:identifier")
} else {
null
}
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val cloneUrls =
when {
cloneOverride != null -> listOf(cloneOverride)
directUrl != null -> listOf(directUrl)
else -> {
val repo =
GitSupport.fetchRepo(ctx, addr!!, args)
?: return Output.error("not_found", "no repository announcement found for $coord")
repo.clones()
}
}
val candidates = candidateUrls(cloneUrls)
if (candidates.isEmpty()) {
return Output.error("bad_args", "repository has no http(s) clone URL (pass --clone URL)")
}
val http = GitHttpClient { ctx.okhttp }
val errors = StringBuilder()
for (url in candidates) {
val snapshot =
try {
http.open(url, ref)
} catch (e: Exception) {
errors
.append(url)
.append("")
.append(e.message ?: e.toString())
.append('\n')
null
}
if (snapshot != null) return block(http, snapshot)
}
return Output.error("unreachable", "could not clone any candidate URL:\n${errors.toString().trim()}")
}
}
/** http(s) clone URLs to try, in order, adding a `.git` variant when missing. */
private fun candidateUrls(cloneUrls: List<String>): List<String> {
val out = LinkedHashSet<String>()
for (raw in cloneUrls) {
val url = raw.trim()
if (!url.startsWith("http://") && !url.startsWith("https://")) continue
out.add(url)
if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git")
}
return out.toList()
}
private fun splitPath(path: String): List<String> =
path
.trim()
.trim('/')
.split('/')
.filter { it.isNotEmpty() }
private fun entrySummary(entry: GitTreeEntry): Map<String, Any?> =
mapOf(
"name" to entry.name,
"type" to
when {
entry.isFolder -> "dir"
entry.isSubmodule -> "submodule"
entry.isSymlink -> "symlink"
else -> "file"
},
"oid" to entry.oid,
)
/** A blob is treated as binary when it contains a NUL byte in its head. */
private fun isBinary(bytes: ByteArray): Boolean {
val end = minOf(8000, bytes.size)
for (i in 0 until end) if (bytes[i].toInt() == 0) return true
return false
}
}

View File

@@ -25,43 +25,73 @@ import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent
import com.vitorpamplona.quartz.nip34Git.state.tags.RefTag
/**
* `amy git <announce|list|show|issue>` — NIP-34 Nostr-native git
* repositories (nak's `git`, adapted to amy's event-publish model).
* `amy git <verb>` — NIP-34 Nostr-native git collaboration, adapted to amy's
* event-publish model. Mirrors the pure-Nostr surface of `nak git` and `ngit`:
* repository announcements + state, patches, pull requests, issues, threaded
* comments (NIP-22), and status updates. Thin assembly only — every event lives
* in quartz's `nip34Git` package; the shared glue is in [GitSupport].
*
* announce publish a kind:30617 repository announcement
* list list a user's repository announcements
* show print one repository announcement (naddr or coordinates)
* issue publish a kind:1621 issue against a repository
*
* nak's clone/push/pull (git-packfile transport over relays/GRASP) are out
* of scope — they need a real git plumbing layer. These are the metadata /
* collaboration events. Thin assembly only: every event lives in quartz
* (`GitRepositoryEvent`, `GitIssueEvent`).
* Out of scope: the git *packfile* transport (`clone` / `fetch` / `push` of real
* git objects to clone/GRASP servers). That needs a git plumbing layer, not an
* event builder — see `cli/ROADMAP.md`.
*/
object GitCommands {
val USAGE: String =
"""
|amy git — NIP-34 Nostr-native git repositories
|amy git — NIP-34 Nostr-native git collaboration
|
| git announce --name N [--description D] publish a kind:30617 repo announcement
| [--clone URL[,URL]] [--web URL[,URL]] (--d / --identifier sets the identifier;
| [--relay URL[,URL]] [--maintainer HEX[,]] defaults to name)
|Repository:
| git init [--name N] [--description D] bootstrap a repo from the local git checkout
| [--clone URL[,URL]] [--relay URL[,URL]] (derives name/clone/earliest-commit/state via
| [--no-state] [--repo PATH] [--d ID] `git`; flags override; publishes 30617 + 30618)
| git announce --name N [--description D] publish a kind:30617 repo announcement
| [--clone URL[,URL]] [--web URL[,URL]] (--d / --identifier sets the identifier;
| [--relay URL[,URL]] [--maintainer HEX[,]] defaults to name)
| [--hashtag T[,T]] [--earliest-commit C]
| [--personal-fork] [--d ID | --identifier ID]
| git list [USER] [--relay URL[,URL]] list a user's repo announcements (default self)
| git show NADDR|kind:pubkey:id print one repo announcement
| [--relay URL[,URL]]
| git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo
| [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin)
| git state REPO|IDENTIFIER publish a kind:30618 repository state
| [--head BRANCH] [--branch name=commit[,…]] (branches/tags as name=commit CSV)
| [--tag name=commit[,…]]
| git list [USER] list a user's repo announcements (default self)
| git show NADDR|kind:pubkey:id print one repo announcement
| git grasp list [USER] | set URL[,URL] a user's GRASP hosting-server list (kind 10317)
|
|Read repo content (git smart-HTTP, read-only — needs a reachable git host):
| git browse REPO [PATH] [--ref R] [--clone URL] list a repo's tree at PATH (default root)
| git cat REPO PATH [--ref R] [--out FILE] print (or write) a file's contents at a ref
| git log REPO [--ref R] [--depth N] recent commit history (most recent first)
|
|Issues / patches / pull requests:
| git issue REPO --subject S [BODY] publish a kind:1621 issue (BODY arg or stdin)
| [--hashtag T[,T]]
| git patch REPO [--file PATH] publish a kind:1617 patch (git format-patch
| [--root|--root-revision] [--commit C] from --file or stdin)
| [--parent-commit P] [--in-reply-to ID]
| git apply PATCH_ID [--check|--print] apply a fetched kind:1617 patch to the local tree
| [--repo PATH] (default: `git am`; --check dry-runs; --print emits it)
| git pr REPO --commit TIP --clone URL[,URL] publish a kind:1618 pull request [DESC arg]
| [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]]
| git pr-update PR --commit TIP --clone URL[,URL] publish a kind:1619 pull-request update
| git issues|patches|prs REPO list a repo's issues/patches/PRs + status
| [--open|--applied|--closed|--draft|--status a,b] [--limit N]
| git thread EVENT_ID print one item + its status timeline + comments
|
|Comments, labels & status:
| git comment TARGET [BODY] NIP-22 kind:1111 comment (BODY arg or stdin)
| git label TARGET LABEL[,LABEL] [--namespace N] NIP-32 kind:1985 labels on an issue/patch/PR
| git open|applied|close|draft TARGET [MESSAGE] publish a kind:1630/1631/1632/1633 status
| applied: [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]]
|
|Every write verb takes [--relay URL[,URL]] to override the target relays
|(default: the repo's advertised relays, else your outbox).
""".trimMargin()
suspend fun dispatch(
@@ -71,12 +101,34 @@ object GitCommands {
route(
"git",
tail,
"git <announce|list|show|issue>",
"git <init|announce|state|list|show|grasp|browse|cat|log|issue|patch|apply|pr|comment|label|open|applied|close|draft|issues|patches|prs|thread>",
mapOf(
"init" to { rest -> GitInitCommand.init(dataDir, rest) },
"announce" to { rest -> announce(dataDir, rest) },
"state" to { rest -> state(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
"show" to { rest -> show(dataDir, rest) },
"grasp" to { rest -> GitGraspCommands.dispatch(dataDir, rest) },
"browse" to { rest -> GitBrowseCommands.browse(dataDir, rest) },
"cat" to { rest -> GitBrowseCommands.cat(dataDir, rest) },
"log" to { rest -> GitBrowseCommands.log(dataDir, rest) },
"issue" to { rest -> issue(dataDir, rest) },
"issues" to { rest -> GitReadCommands.issues(dataDir, rest) },
"patch" to { rest -> GitPatchCommands.patch(dataDir, rest) },
"patches" to { rest -> GitReadCommands.patches(dataDir, rest) },
"pr" to { rest -> GitPrCommands.pr(dataDir, rest) },
"pr-update" to { rest -> GitPrCommands.prUpdate(dataDir, rest) },
"prs" to { rest -> GitReadCommands.prs(dataDir, rest) },
"thread" to { rest -> GitReadCommands.thread(dataDir, rest) },
"comment" to { rest -> GitCommentCommand.comment(dataDir, rest) },
"label" to { rest -> GitLabelCommand.label(dataDir, rest) },
"apply" to { rest -> GitApplyCommand.apply(dataDir, rest) },
"open" to { rest -> GitStatusCommands.open(dataDir, rest) },
"applied" to { rest -> GitStatusCommands.applied(dataDir, rest) },
"merged" to { rest -> GitStatusCommands.applied(dataDir, rest) },
"resolved" to { rest -> GitStatusCommands.applied(dataDir, rest) },
"close" to { rest -> GitStatusCommands.close(dataDir, rest) },
"draft" to { rest -> GitStatusCommands.draft(dataDir, rest) },
),
help = USAGE,
)
@@ -91,14 +143,6 @@ object GitCommands {
// eagerly so passing both spellings doesn't trip rejectUnknown().
val identifierAlias = args.flag("identifier")
val identifier = args.flag("d") ?: identifierAlias ?: name
val csv = { key: String ->
args
.flag(key)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
@@ -106,11 +150,11 @@ object GitCommands {
GitRepositoryEvent.build(
name = name,
description = args.flag("description"),
webUrls = csv("web"),
cloneUrls = csv("clone"),
relays = csv("relay"),
maintainers = csv("maintainer"),
hashtags = csv("hashtag"),
webUrls = GitSupport.csv(args, "web"),
cloneUrls = GitSupport.csv(args, "clone"),
relays = GitSupport.csv(args, "relay"),
maintainers = GitSupport.csv(args, "maintainer"),
hashtags = GitSupport.csv(args, "hashtag"),
earliestUniqueCommit = args.flag("earliest-commit"),
personalFork = args.bool("personal-fork"),
dTag = identifier,
@@ -130,6 +174,49 @@ object GitCommands {
}
}
private suspend fun state(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val ref = args.positional(0, "repo-identifier-or-naddr")
val addr = GitSupport.resolveAddress(ref)
val dTag = addr?.dTag ?: ref
val head = args.flag("head")
val branches = GitSupport.keyValueCsv(args, "branch")
val tagRefs = GitSupport.keyValueCsv(args, "tag")
args.rejectUnknown("relay")
if (branches.isEmpty() && tagRefs.isEmpty() && head == null) {
return Output.error("bad_args", "git state needs at least one --branch, --tag, or --head")
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val refs =
branches.map { RefTag.branch(it.first, it.second) } +
tagRefs.map { RefTag.tag(it.first, it.second) }
val template = GitRepositoryStateEvent.build(dTag = dTag, refs = refs, head = head)
val signed = ctx.signer.sign(template)
// Deliver to the announcement's advertised relays (the announcement may
// be someone else's when we're a maintainer publishing state for it).
val announceOwner = addr?.pubKeyHex ?: ctx.identity.pubKeyHex
val repo = GitSupport.fetchRepo(ctx, Address(GitRepositoryEvent.KIND, announceOwner, dTag), args)
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"address" to Address.assemble(GitRepositoryStateEvent.KIND, signed.pubKey, dTag),
"branches" to branches.size,
"tags" to tagRefs.size,
"head" to head,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
@@ -165,14 +252,14 @@ object GitCommands {
val coord = args.positional(0, "naddr-or-coordinates")
// `--relay` is read later inside fetchRepo's queryTargets.
args.rejectUnknown("relay")
val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)")
val addr = GitSupport.resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)")
if (addr.kind != GitRepositoryEvent.KIND) {
return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})")
}
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
val repo = GitSupport.fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content))
return 0
}
@@ -185,21 +272,15 @@ object GitCommands {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val subject = args.flag("subject") ?: return Output.error("bad_args", "git issue requires --subject")
val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val addr = GitSupport.resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val body = args.positionalOrNull(1) ?: ""
val topics =
args
.flag("hashtag")
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
// `--relay` is read later (relayFlag + fetchRepo's queryTargets).
val topics = GitSupport.csv(args, "hashtag")
// `--relay` is read later (deliveryTargets + fetchRepo's queryTargets).
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
val repo = GitSupport.fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
val template =
GitIssueEvent.build(
subject = subject,
@@ -209,9 +290,7 @@ object GitCommands {
topics = topics,
)
val signed = ctx.signer.sign(template)
// Deliver to the repo's advertised relays when present, else our targets.
val repoRelays = repo.relays().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
val targets = RawEventSupport.relayFlag(args).ifEmpty { repoRelays }.ifEmpty { ctx.outboxRelays() }
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
@@ -226,42 +305,6 @@ object GitCommands {
}
}
// ------------------------------------------------------------------
private suspend fun fetchRepo(
ctx: Context,
addr: Address,
args: Args,
): GitRepositoryEvent? {
// Cache-first, then drain the query relays.
val filter =
Filter(
kinds = listOf(GitRepositoryEvent.KIND),
authors = listOf(addr.pubKeyHex),
tags = mapOf("d" to listOf(addr.dTag)),
limit = 1,
)
ctx.store
.query<Event>(filter)
.firstOrNull()
?.let { return it as? GitRepositoryEvent }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(filter).firstOrNull() as? GitRepositoryEvent
}
/** Accept `naddr1…`, `kind:pubkey:dtag`, or `pubkey:dtag` (kind defaults to 30617). */
private fun resolveAddress(input: String): Address? {
val trimmed = input.trim().removePrefix("nostr:")
if (trimmed.startsWith("naddr")) {
val n = NAddress.parse(trimmed) ?: return null
return Address(n.kind, n.author, n.dTag)
}
Address.parse(trimmed)?.let { return it }
val parts = trimmed.split(":")
return if (parts.size == 2 && parts[0].length == 64) Address(GitRepositoryEvent.KIND, parts[0], parts[1]) else null
}
private fun repoSummary(repo: GitRepositoryEvent): Map<String, Any?> =
mapOf(
"identifier" to repo.dTag(),

View File

@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
/**
* `amy git comment TARGET [BODY]` — reply to a NIP-34 issue, patch, pull
* request, or repository with a NIP-22 kind:1111 comment (the modern
* replacement for the deprecated kind:1622 git reply). TARGET is a
* note/nevent/64-hex event id, or an naddr / `kind:pubkey:id` repo coordinate.
* BODY comes from the argument or stdin.
*/
object GitCommentCommand {
suspend fun comment(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val targetRef = args.positional(0, "target-event-or-repo")
val bodyArg = args.positionalOrNull(1)
// Never block on an interactive TTY: amy is non-interactive, so require the
// body as an argument unless it's actually being piped in.
if (bodyArg == null && System.console() != null) {
return Output.error("bad_args", "comment body required as an argument (or piped on stdin)")
}
val body = (bodyArg ?: System.`in`.readBytes().decodeToString()).trim()
if (body.isBlank()) return Output.error("bad_args", "empty comment (pass BODY as an argument or on stdin)")
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val target =
resolveTarget(ctx, targetRef, args)
?: return Output.error("not_found", "no event or repository found for $targetRef")
val template = CommentEvent.replyBuilder(body, EventHintBundle<Event>(target))
val signed = ctx.signer.sign(template)
// Deliver to the repository's advertised relays when we can find them.
val repo =
(target as? GitRepositoryEvent)
?: GitSupport.repositoryOf(target)?.let { GitSupport.fetchRepo(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) }
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"in_reply_to" to target.id,
"target_kind" to target.kind,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
/** Resolve TARGET as an event id first, then fall back to a repo coordinate. */
private suspend fun resolveTarget(
ctx: Context,
ref: String,
args: Args,
): Event? {
GitSupport.resolveEventId(ref)?.let { id ->
GitSupport.fetchEvent(ctx, id, args)?.let { return it }
}
GitSupport.resolveAddress(ref)?.let { addr ->
if (addr.kind == GitRepositoryEvent.KIND) return GitSupport.fetchRepo(ctx, addr, args)
}
return null
}
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent
/**
* `amy git grasp <list|set>` — a user's NIP-34 GRASP (Git-over-Nostr hosting)
* server list (kind 10317). Functions like NIP-65's relay list: it declares,
* in preference order, where PR tip branches (`refs/nostr/<pr-id>`) get pushed
* so maintainers know where to fetch them. `ngit` and `nak git` read this to
* pick a push host; amy publishes and reads the list (the git push itself is
* out of scope — see cli/ROADMAP.md).
*/
object GitGraspCommands {
val USAGE: String =
"""
|amy git grasp — NIP-34 GRASP server list (kind 10317)
|
| git grasp list [USER] list a user's grasp servers (default self)
| git grasp set URL[,URL] [--relay URL[,URL]] publish your grasp server list (preference order)
""".trimMargin()
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"git grasp",
tail,
"git grasp <list|set>",
mapOf(
"list" to { rest -> list(dataDir, rest) },
"set" to { rest -> set(dataDir, rest) },
),
help = USAGE,
)
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
args.rejectUnknown("relay")
val filter = Filter(kinds = listOf(UserGraspListEvent.KIND), authors = listOf(author), limit = 1)
var event = ctx.store.query<Event>(filter).firstOrNull() as? UserGraspListEvent
if (event == null) {
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
event = ctx.store.query<Event>(filter).firstOrNull() as? UserGraspListEvent
}
val grasps = event?.grasps().orEmpty()
Output.emit(mapOf("pubkey" to author, "count" to grasps.size, "grasps" to grasps))
return 0
}
}
private suspend fun set(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val grasps =
args
.positional(0, "grasp-server-urls")
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
if (grasps.isEmpty()) return Output.error("bad_args", "git grasp set requires URL[,URL] (a comma-separated grasp server list)")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val template = UserGraspListEvent.build(grasps)
val signed = ctx.signer.sign(template)
val targets = RawEventSupport.publishTargets(ctx, args)
args.rejectUnknown()
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"grasps" to grasps,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
}

View File

@@ -0,0 +1,223 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent
import com.vitorpamplona.quartz.nip34Git.state.tags.RefTag
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/**
* `amy git init` — bootstrap a NIP-34 repository from the local git checkout,
* the way `ngit init` does: derive the name, clone URL, earliest-unique-commit,
* and branch/tag state from `git`, then publish the kind:30617 announcement and
* (unless `--no-state`) the kind:30618 state in one shot. Every field can be
* overridden with a flag; when the directory isn't a git repo, the derivation
* is skipped and you supply `--name` / `--clone` yourself.
*
* This is the one `amy git` verb that shells out to `git` — it's inherently
* about the local working tree, exactly like the `ngit`/`nak git` `init`.
*/
object GitInitCommand {
/** Safety ceiling for a single local `git` invocation; a normal read-only op finishes in milliseconds. */
private const val GIT_TIMEOUT_SEC = 60L
suspend fun init(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val repoDir = File(args.flag("repo") ?: ".").absoluteFile
val noState = args.bool("no-state")
val toplevel = git(repoDir, "rev-parse", "--show-toplevel")?.let { File(it) }
val derivedName = toplevel?.name
val originUrl = git(repoDir, "remote", "get-url", "origin")?.let(::normalizeCloneUrl)
// The earliest-unique-commit is the repo's mainline (first-parent) root
// commit — the cross-fork identity every NIP-34 client must agree on. A
// SHALLOW clone cannot know its true root (`rev-list --max-parents=0`
// returns the shallow-boundary commits, not the real first commit), so we
// must NOT derive a bogus euc there — that would announce the repo under a
// wrong identity and fork it away from ngit's view. `--first-parent` keeps
// the mainline root deterministic when a history has merged-in subtree roots.
val shallow = git(repoDir, "rev-parse", "--is-shallow-repository") == "true"
val euc =
if (toplevel == null || shallow) {
null
} else {
git(repoDir, "rev-list", "--max-parents=0", "--first-parent", "HEAD")?.lineSequence()?.lastOrNull { it.isNotBlank() }
}
val name =
args.flag("name") ?: derivedName
?: return Output.error("bad_args", "not a git repo and no --name given (run inside a repo, or pass --name)")
val identifier = args.flag("d") ?: args.flag("identifier") ?: kebab(name)
val cloneUrls = GitSupport.csv(args, "clone").ifEmpty { listOfNotNull(originUrl) }
val earliestCommit = args.flag("earliest-commit") ?: euc
if (earliestCommit == null && toplevel != null) {
System.err.println(
"[git init] warning: could not derive the earliest-unique-commit" +
(if (shallow) " (shallow clone)" else "") +
" — the announcement will omit it. Pass --earliest-commit <root-commit-id> so the repo keeps a stable cross-fork identity.",
)
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val announce =
GitRepositoryEvent.build(
name = name,
description = args.flag("description"),
webUrls = GitSupport.csv(args, "web"),
cloneUrls = cloneUrls,
relays = GitSupport.csv(args, "relay"),
maintainers = GitSupport.csv(args, "maintainer"),
hashtags = GitSupport.csv(args, "hashtag"),
earliestUniqueCommit = earliestCommit,
personalFork = args.bool("personal-fork"),
dTag = identifier,
)
val signedAnnounce = ctx.signer.sign(announce)
val targets = RawEventSupport.publishTargets(ctx, args)
args.rejectUnknown()
val ackAnnounce = ctx.publish(signedAnnounce, targets)
RawEventSupport.publishGuard(ackAnnounce, signedAnnounce.id)?.let { return it }
val result =
mutableMapOf<String, Any?>(
"event_id" to signedAnnounce.id,
"address" to Address.assemble(signedAnnounce.kind, signedAnnounce.pubKey, identifier),
"name" to name,
"clone" to cloneUrls,
"earliest_commit" to earliestCommit,
"from_git_repo" to (toplevel != null),
)
if (!noState && toplevel != null) {
val refs = readRefs(repoDir)
val head = git(repoDir, "symbolic-ref", "--short", "HEAD")
if (refs.isNotEmpty() || head != null) {
val stateTemplate = GitRepositoryStateEvent.build(dTag = identifier, refs = refs, head = head)
val signedState = ctx.signer.sign(stateTemplate)
// Surface the state publish result separately (state_*) rather than
// dropping it — otherwise a fully-rejected 30618 reads as success.
val stateAck = ctx.publish(signedState, targets)
result["state_event_id"] = signedState.id
result["branches"] = refs.count { it.kind == RefTag.Kind.BRANCH }
result["tags"] = refs.count { it.kind == RefTag.Kind.TAG }
result["head"] = head
result.putAll(RawEventSupport.ackFields(stateAck).mapKeys { "state_${it.key}" })
if (stateAck.isNotEmpty() && stateAck.none { it.value.accepted }) {
System.err.println("[git init] warning: no relay accepted the repository-state (30618) event — branches/tags/HEAD were not delivered.")
}
}
}
Output.emit(result + RawEventSupport.ackFields(ackAnnounce))
return 0
}
}
/** Read local branch + tag refs as NIP-34 [RefTag]s via `git for-each-ref`. */
private fun readRefs(repoDir: File): List<RefTag> {
fun parse(
output: String?,
builder: (String, String) -> RefTag,
): List<RefTag> =
output
?.lineSequence()
?.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size == 2 && parts[0].isNotEmpty() && parts[1].isNotEmpty()) builder(parts[0], parts[1]) else null
}?.toList()
.orEmpty()
val branches = parse(git(repoDir, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/heads")) { n, c -> RefTag.branch(n, c) }
val tags = parse(git(repoDir, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/tags")) { n, c -> RefTag.tag(n, c) }
return branches + tags
}
/**
* Run `git <args>` in [repoDir]; returns trimmed stdout on exit 0, else null
* (git missing / not a repo). stderr is discarded straight to the OS so a
* chatty command can never fill its stderr pipe and deadlock the stdout read.
*/
private fun git(
repoDir: File,
vararg gitArgs: String,
): String? =
try {
val proc =
ProcessBuilder(listOf("git", *gitArgs))
.directory(repoDir)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
// Drain stdout on a side thread and bound the wait: a wedged git (a
// hung filter/hook, a credential prompt) must not hang the CLI forever.
val sb = StringBuilder()
val reader = thread(name = "git-out") { runCatching { sb.append(proc.inputStream.readBytes().decodeToString()) } }
if (!proc.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)) {
proc.destroyForcibly()
reader.join(1_000)
null
} else {
reader.join()
if (proc.exitValue() == 0) sb.toString().trim().ifEmpty { null } else null
}
} catch (_: Exception) {
null
}
/** Convert an ssh remote (`git@host:owner/repo.git`, `ssh://…`) to a browsable https URL; pass others through. */
private fun normalizeCloneUrl(url: String): String =
when {
url.startsWith("git@") -> {
val rest = url.removePrefix("git@")
val host = rest.substringBefore(':')
val path = rest.substringAfter(':')
"https://$host/$path"
}
url.startsWith("ssh://git@") -> {
// ssh://git@host[:port]/owner/repo.git → https://host/owner/repo.git
// (drop the SSH port; carrying it into the https URL makes it unreachable).
val rest = url.removePrefix("ssh://git@")
val slash = rest.indexOf('/')
val hostPort = if (slash >= 0) rest.take(slash) else rest
val path = if (slash >= 0) rest.substring(slash) else ""
"https://${hostPort.substringBefore(':')}$path"
}
else -> url
}
/** kebab-case a repo name for the `d` identifier: lowercase, non-alphanumerics collapse to single hyphens. */
private fun kebab(name: String): String =
name
.lowercase()
.replace(Regex("[^a-z0-9]+"), "-")
.trim('-')
.ifEmpty { name }
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag
/**
* `amy git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to a
* patch, pull request, or issue (the `ngit pr label` / `issue label` surface).
* Labels default to the `ugc` namespace; override with `--namespace`.
*/
object GitLabelCommand {
suspend fun label(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val targetRef = args.positional(0, "target-event-id")
val namespace = args.flag("namespace") ?: LabelTag.DEFAULT_NAMESPACE
val labels =
args
.positional(1, "label[,label]")
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { LabelTag(it, namespace) }
if (labels.isEmpty()) return Output.error("bad_args", "git label requires at least one label")
val content = args.flag("content") ?: ""
val id =
GitSupport.resolveEventId(targetRef)
?: return Output.error("bad_args", "expected a note/nevent/64-hex target id")
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val target =
GitSupport.fetchEvent(ctx, id, args)
?: return Output.error("not_found", "no event found for $targetRef")
val template =
LabelEvent.buildEventLabel(
labeledEventId = target.id,
labeledEventAuthor = target.pubKey,
labels = labels,
content = content,
)
val signed = ctx.signer.sign(template)
val repoATag = GitSupport.repositoryOf(target)
val repo = repoATag?.let { GitSupport.fetchRepo(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) }
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"target" to target.id,
"namespace" to namespace,
"labels" to labels.map { it.label },
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import java.io.File
/**
* `amy git patch REPO` — publish a NIP-34 kind:1617 patch against a repository.
*
* The patch body is the raw `git format-patch` output, read from `--file PATH`
* or (by default) stdin, so the natural pipeline is:
*
* git format-patch --stdout HEAD~1 | amy git patch nostr:naddr1… --root
*
* Thin assembly only — every tag lives in quartz's [GitPatchEvent].
*/
object GitPatchCommands {
suspend fun patch(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val addr =
GitSupport.resolveAddress(coord)
?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val root = args.bool("root")
val rootRevision = args.bool("root-revision")
val commit = args.flag("commit")
val parentCommit = args.flag("parent-commit")
val eucOverride = args.flag("earliest-commit")
val replyTo = args.flag("in-reply-to")
val file = args.flag("file")
// `--relay` is consumed later by deliveryTargets / fetchRepo's queryTargets.
args.rejectUnknown("relay")
val body = readPatch(file)
if (body.isBlank()) return Output.error("bad_args", "empty patch (pass --file PATH or pipe `git format-patch` to stdin)")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val repo =
GitSupport.fetchRepo(ctx, addr, args)
?: return Output.error("not_found", "no repository announcement found for $coord")
val euc =
repo.earliestUniqueCommit()
?: eucOverride
?: return Output.error(
"bad_args",
"repository announcement has no earliest-unique-commit; pass --earliest-commit <id>",
)
val repoBundle = EventHintBundle(repo)
val template =
if (replyTo != null) {
val replyId =
GitSupport.resolveEventId(replyTo)
?: return Output.error("bad_args", "--in-reply-to expects a note/nevent/64-hex, got '$replyTo'")
val prior =
GitSupport.fetchEvent(ctx, replyId, args) as? GitPatchEvent
?: return Output.error("not_found", "no patch found to reply to: $replyTo")
GitPatchEvent.reply(
patch = body,
repository = repoBundle,
earliestUniqueCommit = euc,
replyingTo = EventHintBundle(prior),
commit = commit,
parentCommit = parentCommit,
)
} else {
GitPatchEvent.build(
patch = body,
repository = repoBundle,
earliestUniqueCommit = euc,
commit = commit,
parentCommit = parentCommit,
root = root,
rootRevision = rootRevision,
)
}
val signed = ctx.signer.sign(template)
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag),
"subject" to (signed as? GitPatchEvent)?.subject(),
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
/** Read the patch body from [file] when given, otherwise from stdin. */
private fun readPatch(file: String?): String =
if (file != null) {
File(file).takeIf { it.isFile }?.readText()
?: throw IllegalArgumentException("--file not found: $file")
} else {
// Non-interactive: don't block waiting for a human to type a patch.
require(System.console() == null) { "no patch given: pass --file PATH or pipe `git format-patch` to stdin" }
System.`in`.readBytes().decodeToString()
}.trim()
}

View File

@@ -0,0 +1,156 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
/**
* `amy git pr REPO` (kind:1618) and `amy git pr-update PR` (kind:1619) —
* branch-based NIP-34 contributions that reference a pushed tip by clone URL +
* commit id instead of inlining a patch. The actual git branch push (to a clone
* or GRASP server) is out of scope — amy only publishes the collaboration event.
*/
object GitPrCommands {
suspend fun pr(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val addr =
GitSupport.resolveAddress(coord)
?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val currentCommit = args.flag("commit") ?: return Output.error("bad_args", "git pr requires --commit <tip-commit-id>")
val cloneUrls = GitSupport.csv(args, "clone")
if (cloneUrls.isEmpty()) return Output.error("bad_args", "git pr requires --clone <url>[,<url>] where the branch tip can be fetched")
val subject = args.flag("subject")
val branchName = args.flag("branch-name")
val mergeBase = args.flag("merge-base")
val labels = GitSupport.csv(args, "label")
val eucOverride = args.flag("earliest-commit")
val description = args.positionalOrNull(1) ?: ""
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val repo =
GitSupport.fetchRepo(ctx, addr, args)
?: return Output.error("not_found", "no repository announcement found for $coord")
val euc =
repo.earliestUniqueCommit()
?: eucOverride
?: return Output.error("bad_args", "repository announcement has no earliest-unique-commit; pass --earliest-commit <id>")
val template =
GitPullRequestEvent.build(
description = description,
repository = EventHintBundle(repo),
earliestUniqueCommit = euc,
currentCommit = currentCommit,
cloneUrls = cloneUrls,
subject = subject,
labels = labels,
branchName = branchName,
mergeBase = mergeBase,
)
val signed = ctx.signer.sign(template)
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag),
"subject" to subject,
"current_commit" to currentCommit,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
suspend fun prUpdate(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val prRef = args.positional(0, "pull-request-id")
val prId =
GitSupport.resolveEventId(prRef)
?: return Output.error("bad_args", "expected a note/nevent/64-hex pull-request id")
val currentCommit = args.flag("commit") ?: return Output.error("bad_args", "git pr-update requires --commit <new-tip-commit-id>")
val cloneUrls = GitSupport.csv(args, "clone")
if (cloneUrls.isEmpty()) return Output.error("bad_args", "git pr-update requires --clone <url>[,<url>]")
val mergeBase = args.flag("merge-base")
val eucOverride = args.flag("earliest-commit")
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val parent =
GitSupport.fetchEvent(ctx, prId, args) as? GitPullRequestEvent
?: return Output.error("not_found", "no pull request (kind 1618) found for $prRef")
val repoAddr =
parent.repositoryAddress()
?: return Output.error("bad_args", "pull request $prRef carries no repository address")
val repo =
GitSupport.fetchRepo(ctx, repoAddr, args)
?: return Output.error("not_found", "no repository announcement found for ${GitSupport.repoCoordinate(repoAddr)}")
val euc =
repo.earliestUniqueCommit()
?: parent.earliestUniqueCommit()
?: eucOverride
?: return Output.error("bad_args", "no earliest-unique-commit available; pass --earliest-commit <id>")
val template =
GitPullRequestUpdateEvent.build(
parentPullRequest = EventHintBundle(parent),
repository = EventHintBundle<GitRepositoryEvent>(repo),
earliestUniqueCommit = euc,
currentCommit = currentCommit,
cloneUrls = cloneUrls,
mergeBase = mergeBase,
)
val signed = ctx.signer.sign(template)
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"pull_request" to parent.id,
"current_commit" to currentCommit,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
}

View File

@@ -0,0 +1,256 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
/**
* Read-side `amy git` verbs — list a repository's issues / patches / pull
* requests with their derived status, and print one collaboration thread with
* its status timeline and comments. All read-only (anonymous-capable).
*/
object GitReadCommands {
private val STATUS_KINDS =
listOf(
GitStatusEvent.KIND_OPEN,
GitStatusEvent.KIND_APPLIED,
GitStatusEvent.KIND_CLOSED,
GitStatusEvent.KIND_DRAFT,
)
/** Idle timeout for the list reads — tighter than `drainAllPages`' 30s default so a dead relay can't stall a list. */
private const val READ_TIMEOUT_MS = 12_000L
/** Max event ids per `#e` status filter — many relays cap tag-filter values around 100, so stay well under. */
private const val STATUS_ID_CHUNK = 50
suspend fun issues(
dataDir: DataDir,
rest: Array<String>,
): Int = listItems(dataDir, rest, GitIssueEvent.KIND)
suspend fun patches(
dataDir: DataDir,
rest: Array<String>,
): Int = listItems(dataDir, rest, GitPatchEvent.KIND)
suspend fun prs(
dataDir: DataDir,
rest: Array<String>,
): Int = listItems(dataDir, rest, GitPullRequestEvent.KIND)
/** Shared list path for issues (1621) / patches (1617) / pull requests (1618). */
private suspend fun listItems(
dataDir: DataDir,
rest: Array<String>,
itemKind: Int,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val addr =
GitSupport.resolveAddress(coord)
?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val limit = args.intFlag("limit", 100)
val wanted = statusFilter(args)
args.rejectUnknown("relay")
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val repoAddress = GitSupport.repoCoordinate(addr)
// Fetch the announcement once: it supplies both the maintainer set
// (status authority) AND the advertised relays the events actually live
// on. Reading only from general relays misses GRASP-hosted repos.
val repo = GitSupport.fetchRepo(ctx, addr, args)
val relays = GitSupport.readTargets(ctx, repo, args)
// Two queries so item volume and status volume never starve each other.
// Items are paged to the limit; statuses are fetched separately by the
// items' ids so derived status is never truncated by item volume.
val itemEvents =
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) },
timeoutMs = READ_TIMEOUT_MS,
).asSequence()
.map { it.second }
.filter { it.kind == itemKind }
.distinctBy { it.id }
.sortedByDescending { it.createdAt }
.take(limit)
.toList()
val statusesByRoot = fetchStatusesFor(ctx, relays, itemEvents.map { it.id }).groupBy { it.rootEventId() }
val authorities = repoAuthorities(repo, addr)
val items =
itemEvents
.map { item -> GitSupport.targetSummary(item) + mapOf("status" to latestStatus(item, statusesByRoot, authorities)) }
.filter { wanted == null || it["status"] in wanted }
Output.emit(mapOf("repository" to repoAddress, "count" to items.size, "items" to items))
return 0
}
}
/**
* `amy git thread TARGET` — the target event plus its status timeline and
* NIP-22 comments (and legacy kind:1622 replies).
*
* Scope: first-level replies/statuses that `e`-reference the target. Nested
* comment trees and PR-update (1619) events (which use NIP-22 uppercase `E`)
* are out of scope here.
*/
suspend fun thread(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val ref = args.positional(0, "target-event-id")
val id =
GitSupport.resolveEventId(ref)
?: return Output.error("bad_args", "expected a note/nevent/64-hex event id")
args.rejectUnknown("relay")
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val target =
GitSupport.fetchEvent(ctx, id, args)
?: return Output.error("not_found", "no event found for $ref")
val repoATag = GitSupport.repositoryOf(target)
val repo = repoATag?.let { GitSupport.fetchRepo(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) }
val relays = GitSupport.readTargets(ctx, repo, args)
// Everything that `e`-references the target: statuses, comments, replies.
val related =
ctx
.drain(
relays.associateWith {
listOf(Filter(kinds = STATUS_KINDS + listOf(CommentEvent.KIND, GitReplyEvent.KIND), tags = mapOf("e" to listOf(id))))
},
timeoutMs = READ_TIMEOUT_MS,
).map { it.second }
.distinctBy { it.id }
val authorities = repoATag?.let { repoAuthorities(repo, Address(it.kind, it.pubKeyHex, it.dTag)) } ?: setOf(target.pubKey)
val statuses = related.filterIsInstance<GitStatusEvent>().filter { it.rootEventId() == id }
val statusesByRoot = statuses.groupBy { it.rootEventId() }
val comments =
related
.filter { it.kind == CommentEvent.KIND || it.kind == GitReplyEvent.KIND }
.sortedBy { it.createdAt }
.map { mapOf("event_id" to it.id, "kind" to it.kind, "author" to it.pubKey, "created_at" to it.createdAt, "content" to it.content) }
Output.emit(
GitSupport.targetSummary(target) +
mapOf(
"content" to target.content,
"status" to latestStatus(target, statusesByRoot, authorities),
"status_events" to
statuses.sortedBy { it.createdAt }.map {
mapOf("event_id" to it.id, "status" to GitSupport.statusLabel(it.kind), "author" to it.pubKey, "created_at" to it.createdAt)
},
"comments" to comments,
),
)
return 0
}
}
// ------------------------------------------------------------------
/**
* Fetch the status events that `e`-reference [itemIds]. Chunked to stay under
* relay tag-filter value caps, and paged (`drainAllPages`) so a heavily
* reopened/closed item's older status can't fall off a single page.
*/
private suspend fun fetchStatusesFor(
ctx: Context,
relays: Set<NormalizedRelayUrl>,
itemIds: List<HexKey>,
): List<GitStatusEvent> {
if (itemIds.isEmpty()) return emptyList()
return itemIds
.chunked(STATUS_ID_CHUNK)
.flatMap { chunk ->
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to chunk))) },
timeoutMs = READ_TIMEOUT_MS,
).map { it.second }
}.filterIsInstance<GitStatusEvent>()
.distinctBy { it.id }
}
/** The set of pubkeys whose status is authoritative for a repo: the owner + declared maintainers. */
private fun repoAuthorities(
repo: GitRepositoryEvent?,
addr: Address,
): Set<HexKey> =
buildSet {
add(addr.pubKeyHex)
repo?.maintainers()?.let { addAll(it) }
}
/**
* The authoritative status label for [item]: the newest status event (by
* `created_at`, id as a deterministic tie-break) that `e`-roots this item and
* is signed by the item author, the repo owner, or a maintainer. Defaults to
* `open` when none exists. [statusesByRoot] is pre-grouped by root event id so
* this is an O(1) lookup instead of an O(items × statuses) rescan.
*/
private fun latestStatus(
item: Event,
statusesByRoot: Map<HexKey?, List<GitStatusEvent>>,
authorities: Set<HexKey>,
): String {
val allowed = authorities + item.pubKey
val newest =
statusesByRoot[item.id]
?.filter { it.pubKey in allowed }
?.maxWithOrNull(compareBy({ it.createdAt }, { it.id }))
return GitSupport.statusLabel(newest?.kind)
}
/** Translate `--status a,b` plus the `--open/--applied/--closed/--draft/--all` bools into a wanted set (null = all). */
private fun statusFilter(args: Args): Set<String>? {
val explicit = GitSupport.csv(args, "status").toMutableSet()
if (args.bool("open")) explicit.add("open")
if (args.bool("applied")) explicit.add("applied")
if (args.bool("closed")) explicit.add("closed")
if (args.bool("draft")) explicit.add("draft")
args.bool("all") // consumed; means "no filter"
return explicit.takeIf { it.isNotEmpty() }
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
/**
* `amy git open|applied|close|draft TARGET` — publish a NIP-34 status event
* (kinds 1630 / 1631 / 1632 / 1633) against a patch, pull request, or issue.
*
* open 1630 — mark open / reopen / ready-for-review
* applied 1631 — mark applied / merged (patches, PRs) or resolved (issues)
* close 1632 — close without applying
* draft 1633 — move back to draft
*
* The newest status from the root author or a repo maintainer is authoritative
* (NIP-34). amy publishes the event; it does not enforce maintainership.
*/
object GitStatusCommands {
suspend fun open(
dataDir: DataDir,
rest: Array<String>,
): Int = simpleStatus(dataDir, rest, GitStatusOpenEvent.KIND)
suspend fun close(
dataDir: DataDir,
rest: Array<String>,
): Int = simpleStatus(dataDir, rest, GitStatusClosedEvent.KIND)
suspend fun draft(
dataDir: DataDir,
rest: Array<String>,
): Int = simpleStatus(dataDir, rest, GitStatusDraftEvent.KIND)
/** open / close / draft share one shape: `TARGET [MESSAGE]`. */
private suspend fun simpleStatus(
dataDir: DataDir,
rest: Array<String>,
kind: Int,
): Int {
val args = Args(rest)
val (targetRef, msg) = args.targetAndMessage() ?: return Output.error("bad_args", "expected a note/nevent/64-hex target id")
args.rejectUnknown("relay")
return Context.open(dataDir).use { ctx ->
ctx.prepare()
val target = ctx.resolveTarget(targetRef, args) ?: return@use Output.error("not_found", "no event found for $targetRef")
val repoATag = GitSupport.repositoryOf(target)
val template =
when (kind) {
GitStatusOpenEvent.KIND -> GitStatusOpenEvent.build(msg, EventHintBundle(target)) { repoTags(repoATag, target) }
GitStatusClosedEvent.KIND -> GitStatusClosedEvent.build(msg, EventHintBundle(target)) { repoTags(repoATag, target) }
GitStatusDraftEvent.KIND -> GitStatusDraftEvent.build(msg, EventHintBundle(target)) { repoTags(repoATag, target) }
else -> error("unreachable status kind $kind")
}
ctx.emitStatus(template, target, repoATag, args, kind)
}
}
/** `applied` (1631) additionally carries merge-commit / applied-as-commits / applied-patch tags. */
suspend fun applied(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val (targetRef, msg) = args.targetAndMessage() ?: return Output.error("bad_args", "expected a note/nevent/64-hex target id")
val mergeCommit = args.flag("merge-commit")
val appliedAsCommits = GitSupport.csv(args, "commit")
val patchRefs = GitSupport.csv(args, "patch")
args.rejectUnknown("relay")
return Context.open(dataDir).use { ctx ->
ctx.prepare()
val target = ctx.resolveTarget(targetRef, args) ?: return@use Output.error("not_found", "no event found for $targetRef")
val repoATag = GitSupport.repositoryOf(target)
val appliedPatches =
patchRefs.mapNotNull { ref ->
GitSupport.resolveEventId(ref)?.let { id -> GitSupport.fetchEvent(ctx, id, args) as? GitPatchEvent }?.let { EventHintBundle(it) }
}
val template =
GitStatusAppliedEvent.build(
content = msg,
target = EventHintBundle(target),
appliedPatches = appliedPatches,
mergeCommit = mergeCommit,
appliedAsCommits = appliedAsCommits,
) { repoTags(repoATag, target) }
ctx.emitStatus(template, target, repoATag, args, GitStatusAppliedEvent.KIND)
}
}
// ------------------------------------------------------------------
/** `TARGET [MESSAGE]` — the shape every status verb shares. */
private fun Args.targetAndMessage(): Pair<String, String>? {
val ref = positionalOrNull(0) ?: return null
return ref to (positionalOrNull(1) ?: "")
}
private suspend fun Context.resolveTarget(
ref: String,
args: Args,
): Event? {
val id = GitSupport.resolveEventId(ref) ?: return null
return GitSupport.fetchEvent(this, id, args)
}
/**
* Attach the repository `a` tag and, when it differs from the target author
* (already p-tagged by the shared status builder), the repo owner `p` tag.
*/
private fun <T : Event> TagArrayBuilder<T>.repoTags(
repoATag: ATag?,
target: Event,
) {
repoATag ?: return
add(repoATag.toATagArray())
if (repoATag.pubKeyHex != target.pubKey) pTag(repoATag.pubKeyHex)
}
private suspend fun Context.emitStatus(
template: EventTemplate<out Event>,
target: Event,
repoATag: ATag?,
args: Args,
kind: Int,
): Int {
val signed = signer.sign(template)
val repo = repoATag?.let { GitSupport.fetchRepo(this, Address(it.kind, it.pubKeyHex, it.dTag), args) }
val targets = GitSupport.deliveryTargets(this, repo, args)
val ack = publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"status" to GitSupport.statusLabel(kind),
"target" to target.id,
"repository" to repoATag?.toTag(),
) + RawEventSupport.ackFields(ack),
)
return 0
}
}

View File

@@ -0,0 +1,247 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
/**
* Shared, protocol-free glue for the `amy git` (NIP-34) sub-verbs — address /
* event-id parsing, cache-first fetch helpers, relay routing, and the read-side
* summaries. Every real Nostr piece lives in quartz's `nip34Git` package; this
* object only parses CLI input and shapes the `--json` result maps.
*/
object GitSupport {
/** Accept `naddr1…`, `kind:pubkey:dtag`, or `pubkey:dtag` (kind defaults to 30617). */
fun resolveAddress(input: String): Address? {
val trimmed = input.trim().removePrefix("nostr:")
if (trimmed.startsWith("naddr")) {
val n = NAddress.parse(trimmed) ?: return null
return Address(n.kind, n.author, n.dTag)
}
Address.parse(trimmed)?.let { return it }
val parts = trimmed.split(":")
return if (parts.size == 2 && parts[0].length == 64) Address(GitRepositoryEvent.KIND, parts[0], parts[1]) else null
}
/** Decode a `note1…` / `nevent1…` / 64-hex event reference to its raw event id. */
fun resolveEventId(input: String): String? = decodeEventIdAsHexOrNull(input.trim().removePrefix("nostr:"))
/** The `["a", …]` coordinate value of a repository announcement (`30617:pubkey:identifier`). */
fun repoCoordinate(addr: Address): String = Address.assemble(GitRepositoryEvent.KIND, addr.pubKeyHex, addr.dTag)
/**
* Cache-first fetch of a repository announcement (kind 30617) for [addr],
* draining the query relays only on a store miss.
*/
suspend fun fetchRepo(
ctx: Context,
addr: Address,
args: Args,
): GitRepositoryEvent? {
val filter =
Filter(
kinds = listOf(GitRepositoryEvent.KIND),
authors = listOf(addr.pubKeyHex),
tags = mapOf("d" to listOf(addr.dTag)),
limit = 1,
)
ctx.store
.query<Event>(filter)
.firstOrNull()
?.let { return it as? GitRepositoryEvent }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(filter).firstOrNull() as? GitRepositoryEvent
}
/**
* Cache-first fetch of any event by its raw [idHex], draining the query
* relays only on a store miss. Used to resolve the patch / PR / issue a
* status, comment, or thread view targets.
*/
suspend fun fetchEvent(
ctx: Context,
idHex: String,
args: Args,
): Event? {
val filter = Filter(ids = listOf(idHex), limit = 1)
ctx.store
.query<Event>(filter)
.firstOrNull()
?.let { return it }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(filter).firstOrNull()
}
/** The repository this issue / patch / PR / status refers to, as an [ATag] (or null). */
fun repositoryOf(event: Event): ATag? =
when (event) {
is GitIssueEvent -> event.repository()
is GitPatchEvent -> event.repository()
is GitPullRequestEvent -> event.repository()
is GitPullRequestUpdateEvent -> event.repository()
is GitStatusEvent -> event.repository()
else -> null
}
/**
* Where to deliver a collaboration event: the repo announcement's advertised
* `relays` (the NIP-34 monitored set), else the explicit `--relay` flag, else
* the account outbox. [repo] is the fetched announcement, or null when it
* couldn't be resolved.
*/
suspend fun deliveryTargets(
ctx: Context,
repo: GitRepositoryEvent?,
args: Args,
): Set<NormalizedRelayUrl> {
val flag = RawEventSupport.relayFlag(args)
if (flag.isNotEmpty()) return flag
val advertised =
repo
?.relays()
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
?.toSet()
.orEmpty()
if (advertised.isNotEmpty()) return advertised
// Falling back to the account outbox: the repo couldn't be resolved or
// advertises no relays, so a maintainer watching only the repo's NIP-34
// relays may never see this event. Say so instead of reporting silent success.
System.err.println(
"[git] warning: no repository relays known — delivering to your outbox. " +
"A maintainer watching only the repo's relays may not see this; pass --relay to target them.",
)
return ctx.outboxRelays()
}
/**
* Where to READ a repo's collaboration events from: the account's query
* relays (explicit `--relay`, else outbox, else bootstrap) UNION the repo
* announcement's own advertised `relays` — the NIP-34 monitored set where
* patches/issues/statuses actually live. Without the union, a repo hosted on
* GRASP/git-specific relays (which general relays don't mirror) reads empty.
*/
suspend fun readTargets(
ctx: Context,
repo: GitRepositoryEvent?,
args: Args,
): Set<NormalizedRelayUrl> {
val advertised =
repo
?.relays()
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
?.toSet()
.orEmpty()
return RawEventSupport.queryTargets(ctx, args) + advertised
}
/** Parse a `--flag a,b,c` CSV flag into a trimmed, non-empty list. */
fun csv(
args: Args,
key: String,
): List<String> =
args
.flag(key)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
/**
* Parse a `--flag name=commit,other=commit` CSV of `key=value` pairs (used
* for `git state --branch`/`--tag`). An entry without `=` is a bad-args
* error so a typo can't silently drop a ref.
*/
fun keyValueCsv(
args: Args,
key: String,
): List<Pair<String, String>> =
csv(args, key).map { entry ->
val idx = entry.indexOf('=')
require(idx > 0 && idx < entry.length - 1) { "--$key expects name=commit pairs, got '$entry'" }
entry.take(idx).trim() to entry.substring(idx + 1).trim()
}
/** The single-word status of a patch/PR/issue, derived from its status events. */
fun statusLabel(kind: Int?): String =
when (kind) {
GitStatusEvent.KIND_OPEN -> "open"
GitStatusEvent.KIND_APPLIED -> "applied"
GitStatusEvent.KIND_CLOSED -> "closed"
GitStatusEvent.KIND_DRAFT -> "draft"
null -> "open"
else -> "open"
}
/** A compact `--json`-friendly summary of an issue / patch / PR event. */
fun targetSummary(event: Event): Map<String, Any?> {
val base =
mutableMapOf<String, Any?>(
"event_id" to event.id,
"kind" to event.kind,
"author" to event.pubKey,
"created_at" to event.createdAt,
)
when (event) {
is GitIssueEvent -> {
base["type"] = "issue"
base["subject"] = event.subject()
base["labels"] = event.topics()
}
is GitPatchEvent -> {
base["type"] = "patch"
base["subject"] = event.subject()
base["commit"] = event.commit()
base["parent_commit"] = event.parentCommit()
base["root"] = event.isRoot()
}
is GitPullRequestEvent -> {
base["type"] = "pull-request"
base["subject"] = event.subject()
base["current_commit"] = event.currentCommit()
base["clone"] = event.cloneUrls()
base["branch_name"] = event.branchName()
base["labels"] = event.labels()
}
is GitPullRequestUpdateEvent -> {
base["type"] = "pull-request-update"
base["current_commit"] = event.currentCommit()
}
}
return base
}
}

View File

@@ -6,3 +6,4 @@ clink/state-clink-headless/
relaygroup/state-relaygroup-headless/
sync/state-sync-deletions/
blossom/state-blossom-live/
git/state-git-nip34/

View File

@@ -2,7 +2,7 @@
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a
loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers,
or no relay at all, depending on the suite. Ten directories:
or no relay at all, depending on the suite. Eleven directories:
```
cli/tests/
@@ -19,6 +19,8 @@ cli/tests/
│ ├── dm-interop-headless.sh
│ ├── setup.sh # preflight + identities
│ └── tests-dm.sh
├── git/ # NIP-34 git collaboration vs embedded `amy serve`
│ └── git-nip34-headless.sh
├── marmot/ # Marmot / MLS group-messaging interop
│ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI
│ ├── marmot-interop-headless.sh # zero-prompt
@@ -65,6 +67,19 @@ Suite notes:
- **`sync/sync-deletions-headless.sh`** proves NIP-77 deletion propagation
both directions (plus the `--no-sync-deletions` opt-out) against
`amy serve`, with one `$HOME` per account so stores don't share.
- **`git/git-nip34-headless.sh`** drives the full NIP-34 collaboration surface
against `amy serve`: `git init` bootstrapping a repo from the harness's own
git checkout (announce + state derived via `git`), announce (30617) + state
(30618) + GRASP list (10317),
issue (1621), patch (1617), pull request (1618) + update (1619), NIP-22
comment (1111), NIP-32 label (1985), and status events (1630-1633). It also
publishes a real `git format-patch` and `git apply`s it back into a scratch
working tree, and asserts the `issues`/`patches`/`prs`/`thread` reads derive
the right status (a closed issue reads `closed`, an applied PR reads
`applied`) and that `--open`/`--closed` filter correctly. Pass `--live` to additionally exercise
the git smart-HTTP reads (`git browse`/`cat`/`log`) against a real public
repo (`$LIVE_REPO`, default octocat/Hello-World) — skipped by default since
it needs a reachable git host.
The Marmot harnesses come in two flavours, same scenarios:

View File

@@ -0,0 +1,331 @@
#!/usr/bin/env bash
#
# git-nip34-headless.sh — drives the real `amy` binary against a real
# `amy serve` relay to prove the NIP-34 (git-over-nostr) collaboration surface
# end-to-end: repository announcement + state, patches, pull requests, issues,
# NIP-22 comments, and status events — plus the status-deriving reads.
#
# The verbs mirror the pure-Nostr surface of `ngit` and `nak git`. The git
# packfile transport (clone/fetch/push of real objects) is intentionally out of
# scope, so this harness only exercises the events amy publishes and reads back.
#
# Flow (single maintainer account against one relay):
# announce (30617) → state (30618) → issue (1621) → patch (1617) →
# pr (1618) → pr-update (1619) → comment (1111) → close the issue (1632) →
# mark the pr applied (1631) → list issues/patches/prs (status derived) →
# thread view (status timeline + comments).
#
# Usage: ./git-nip34-headless.sh [--port N] [--no-build]
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-git-nip34"
LOG_DIR="$STATE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
RELAY_HOST="127.0.0.1"
RELAY_PORT="${RELAY_PORT:-7793}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
NO_BUILD=0
LIVE=0
LIVE_REPO="${LIVE_REPO:-https://github.com/octocat/Hello-World.git}"
while [[ $# -gt 0 ]]; do
case "$1" in
--port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
--no-build) NO_BUILD=1 ;;
--live) LIVE=1 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
shift
done
rm -rf "$STATE_DIR"
mkdir -p "$LOG_DIR"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
assert_eq() {
local actual="$1" expected="$2" test_id="$3" note="${4:-}"
if [[ "${actual// /}" == "${expected// /}" ]]; then
info "assert: $test_id \"$actual\" == \"$expected\""
record_result "$test_id" pass "$note"
return 0
fi
fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})"
record_result "$test_id" fail "${note:-mismatch}"
return 1
}
assert_nonempty() {
local actual="$1" test_id="$2" note="${3:-}"
if [[ -n "$actual" && "$actual" != "null" ]]; then
info "assert: $test_id nonempty (\"$actual\")"
record_result "$test_id" pass "$note"
return 0
fi
fail_msg "$test_id: expected a value, got empty/null (${note:-})"
record_result "$test_id" fail "${note:-empty}"
return 1
}
SERVE_PID=""
cleanup() {
[[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null
trap - EXIT INT TERM HUP
print_summary
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
banner "amy git — NIP-34 collaboration headless ($RUN_TS)"
# ---- build ------------------------------------------------------------------
if [[ "$NO_BUILD" -eq 0 ]]; then
step "Building amy (installDist)…"
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \
|| { fail_msg "build failed (see $LOG_FILE)"; exit 1; }
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary not found at $AMY_BIN"; exit 1; }
strip() { grep -vE "Picked up JAVA_TOOL|DEBUG:|INFO:"; }
mk_home() { mktemp -d "$STATE_DIR/home.XXXXXX"; }
# amy_run <home> <account> args...
amy_run() {
local home="$1" acct="$2"; shift 2
HOME="$home" "$AMY_BIN" --account "$acct" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE" | strip
}
M_HOME="$(mk_home)"
amy_run "$M_HOME" m init >/dev/null
M() { amy_run "$M_HOME" m "$@"; }
step "Starting amy serve on $RELAY_URL"
HOME="$M_HOME" "$AMY_BIN" --account m --secret-backend plaintext \
serve --host "$RELAY_HOST" --port "$RELAY_PORT" >>"$LOG_FILE" 2>&1 &
SERVE_PID=$!
for _ in $(seq 1 60); do
grep -q "relay up at" "$LOG_FILE" && break
sleep 0.5
done
grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; }
# =============================================================================
# git init — bootstrap from a local (full, non-shallow) git checkout
# =============================================================================
banner "git init (from a full scratch checkout)"
INITREPO="$(mk_home)/initrepo"
git init -q "$INITREPO"
git -C "$INITREPO" config user.email a@b.c
git -C "$INITREPO" config user.name t
echo "hello" >"$INITREPO/README.md"
git -C "$INITREPO" add README.md
git -C "$INITREPO" commit -qm "initial commit"
ROOT_COMMIT="$(git -C "$INITREPO" rev-list --max-parents=0 --first-parent HEAD | tail -1)"
INIT="$(M git init --repo "$INITREPO" --relay "$RELAY_URL")"
info "init: $INIT"
assert_eq "$(echo "$INIT" | jq -r '.from_git_repo')" "true" init.from_git "init derived fields from the git repo"
assert_nonempty "$(echo "$INIT" | jq -r '.name')" init.name "repo name derived"
assert_eq "$(echo "$INIT" | jq -r '.earliest_commit')" "$ROOT_COMMIT" init.euc "earliest-unique-commit is the first-parent root"
assert_nonempty "$(echo "$INIT" | jq -r '.state_event_id')" init.state "init also published a 30618 state event"
# A SHALLOW clone must NOT invent an euc (it can't know the true root).
SHALLOWREPO="$(mk_home)/shallowrepo"
git clone -q --depth 1 "file://$INITREPO" "$SHALLOWREPO" 2>/dev/null || git clone -q --depth 1 "$INITREPO" "$SHALLOWREPO"
if [[ "$(git -C "$SHALLOWREPO" rev-parse --is-shallow-repository)" == "true" ]]; then
SINIT="$(M git init --repo "$SHALLOWREPO" --d shallow-test --relay "$RELAY_URL")"
assert_eq "$(echo "$SINIT" | jq -r '.earliest_commit')" "null" init.shallow_euc "shallow clone omits the euc (no wrong identity)"
else
skip_msg "could not create a shallow clone for init.shallow_euc"
fi
# =============================================================================
# Repository announcement (30617) + state (30618)
# =============================================================================
banner "announce + state"
ANN="$(M git announce --name demo-repo --description "a demo" \
--clone https://example.com/demo.git --earliest-commit abc123 --relay "$RELAY_URL")"
info "announce: $ANN"
ADDR="$(echo "$ANN" | jq -r '.address')"
assert_nonempty "$ADDR" announce.address "kind:pubkey:id coordinate returned"
assert_eq "$(echo "$ANN" | jq -r '.published_to | length')" "1" announce.published "relay accepted 30617"
STATE="$(M git state "$ADDR" --head main --branch main=deadbeef,dev=cafe00 --tag v1.0=abc123 --relay "$RELAY_URL")"
info "state: $STATE"
assert_eq "$(echo "$STATE" | jq -r '.branches')" "2" state.branches "two branch refs"
assert_eq "$(echo "$STATE" | jq -r '.head')" "main" state.head "HEAD=main"
# --- ngit interop: wire-format checks --------------------------------------
banner "ngit interop wire format"
# Announce a repo with TWO clone URLs; NIP-34/ngit want ONE multi-value tag.
IADDR="$(M git announce --name interop --clone https://a.git,https://b.git --web https://a.com,https://b.com --earliest-commit abc123 --relay "$RELAY_URL" | jq -r '.address')"
IEID="$(M git show "$IADDR" --relay "$RELAY_URL" | jq -r '.event_id')"
ITAGS="$(M fetch --id "$IEID" --relay "$RELAY_URL")"
assert_eq "$(echo "$ITAGS" | jq -r '[.events[0].tags[] | select(.[0]=="clone")] | length')" "1" interop.clone_single "clone is one tag (not repeated)"
assert_eq "$(echo "$ITAGS" | jq -r '.events[0].tags[] | select(.[0]=="clone") | length')" "3" interop.clone_multivalue "clone tag carries both URLs (name + 2 values)"
# The tolerant reader must round-trip both URLs back.
assert_eq "$(M git show "$IADDR" --relay "$RELAY_URL" | jq -r '.clone | length')" "2" interop.clone_read "reader returns both clone URLs"
# Issue must p-tag the repository owner (maintainer routing).
IISS="$(M git issue "$IADDR" --subject "interop" "b" --relay "$RELAY_URL" | jq -r '.event_id')"
IOWNER="$(echo "$IADDR" | cut -d: -f2)"
assert_eq "$(M fetch --id "$IISS" --relay "$RELAY_URL" | jq -r "[.events[0].tags[] | select(.[0]==\"p\" and .[1]==\"$IOWNER\")] | length")" "1" interop.issue_ptag "issue carries the repo owner p tag"
# A PR (1618) also carries clone URLs as ONE multi-value tag (same fix as 30617).
IPR="$(M git pr "$IADDR" --commit tip1 --clone https://c1.git,https://c2.git --subject s --relay "$RELAY_URL" | jq -r '.event_id')"
IPRTAGS="$(M fetch --id "$IPR" --relay "$RELAY_URL")"
assert_eq "$(echo "$IPRTAGS" | jq -r '[.events[0].tags[] | select(.[0]=="clone")] | length')" "1" interop.pr_clone_single "PR clone is one tag"
assert_eq "$(echo "$IPRTAGS" | jq -r '.events[0].tags[] | select(.[0]=="clone") | length')" "3" interop.pr_clone_multivalue "PR clone tag carries both URLs"
# GRASP server list (10317) round-trip.
GRASP="$(M git grasp set "wss://grasp.example.com,wss://grasp2.example.com" --relay "$RELAY_URL")"
assert_eq "$(echo "$GRASP" | jq -r '.kind')" "10317" grasp.kind "grasp list is kind 10317"
GRASP_READ="$(M git grasp list --relay "$RELAY_URL")"
assert_eq "$(echo "$GRASP_READ" | jq -r '.count')" "2" grasp.count "two grasp servers read back"
assert_eq "$(echo "$GRASP_READ" | jq -r '.grasps[0]')" "wss://grasp.example.com" grasp.order "preference order preserved"
# =============================================================================
# Issue (1621) + patch (1617) + PR (1618) + PR update (1619)
# =============================================================================
banner "issue / patch / pr / pr-update"
ISS="$(M git issue "$ADDR" --subject "First bug" "the issue body" --relay "$RELAY_URL")"
ISSID="$(echo "$ISS" | jq -r '.event_id')"
assert_eq "$(echo "$ISS" | jq -r '.kind')" "1621" issue.kind "issue is kind 1621"
assert_nonempty "$ISSID" issue.id "issue event id"
PATCH="$(printf 'From abc\nSubject: [PATCH] fix the thing\n\ndiff --git a/x b/x\n' \
| M git patch "$ADDR" --root --commit deadbeef --relay "$RELAY_URL")"
info "patch: $PATCH"
assert_eq "$(echo "$PATCH" | jq -r '.kind')" "1617" patch.kind "patch is kind 1617"
assert_eq "$(echo "$PATCH" | jq -r '.subject')" "fix the thing" patch.subject "subject parsed from format-patch"
PR="$(M git pr "$ADDR" --commit feed01 --clone https://example.com/demo.git \
--subject "add feature" "pr description" --relay "$RELAY_URL")"
PRID="$(echo "$PR" | jq -r '.event_id')"
assert_eq "$(echo "$PR" | jq -r '.kind')" "1618" pr.kind "pr is kind 1618"
PRUP="$(M git pr-update "$PRID" --commit feed02 --clone https://example.com/demo.git --relay "$RELAY_URL")"
assert_eq "$(echo "$PRUP" | jq -r '.kind')" "1619" prupdate.kind "pr-update is kind 1619"
# =============================================================================
# Comment (1111) + status events (1632 close, 1631 applied)
# =============================================================================
banner "comment + status"
CMT="$(M git comment "$ISSID" "thanks for reporting" --relay "$RELAY_URL")"
assert_eq "$(echo "$CMT" | jq -r '.kind')" "1111" comment.kind "comment is NIP-22 kind 1111"
LABEL="$(M git label "$ISSID" "bug,help-wanted" --relay "$RELAY_URL")"
assert_eq "$(echo "$LABEL" | jq -r '.kind')" "1985" label.kind "label is NIP-32 kind 1985"
assert_eq "$(echo "$LABEL" | jq -r '.labels | length')" "2" label.count "two labels attached"
CLOSE="$(M git close "$ISSID" "wontfix" --relay "$RELAY_URL")"
assert_eq "$(echo "$CLOSE" | jq -r '.kind')" "1632" close.kind "close status is kind 1632"
APPLIED="$(M git applied "$PRID" "merged it" --merge-commit feed99 --commit feed02 --relay "$RELAY_URL")"
assert_eq "$(echo "$APPLIED" | jq -r '.kind')" "1631" applied.kind "applied status is kind 1631"
# =============================================================================
# git apply — publish a real patch and apply it to a local working tree
# =============================================================================
banner "git apply (nostr patch → local git am)"
SCRATCH="$(mk_home)/scratch"
git init -q "$SCRATCH"
git -C "$SCRATCH" config user.email a@b.c
git -C "$SCRATCH" config user.name t
echo "line1" >"$SCRATCH/f.txt"
git -C "$SCRATCH" add f.txt
git -C "$SCRATCH" commit -qm "init"
# Make a real commit, capture its format-patch, then roll it back so `apply` can re-add it.
echo "line2" >>"$SCRATCH/f.txt"
git -C "$SCRATCH" commit -qam "add line2"
PATCHTXT="$(git -C "$SCRATCH" format-patch -1 --stdout)"
git -C "$SCRATCH" reset -q --hard HEAD~1
APATCH="$(printf '%s' "$PATCHTXT" | M git patch "$ADDR" --root --relay "$RELAY_URL")"
APID="$(echo "$APATCH" | jq -r '.event_id')"
CHECK="$(M git apply "$APID" --check --repo "$SCRATCH")"
assert_eq "$(echo "$CHECK" | jq -r '.mode')" "check" apply.check "apply --check dry-runs cleanly"
APPLY="$(M git apply "$APID" --repo "$SCRATCH")"
info "apply: $APPLY"
assert_eq "$(echo "$APPLY" | jq -r '.applied')" "true" apply.applied "patch applied via git am"
assert_eq "$(git -C "$SCRATCH" log --oneline | head -1 | sed 's/^[0-9a-f]* //')" "add line2" apply.commit "commit landed in the working tree"
# =============================================================================
# Status-deriving reads
# =============================================================================
banner "reads derive status"
ISSUES="$(M git issues "$ADDR" --relay "$RELAY_URL")"
info "issues: $ISSUES"
assert_eq "$(echo "$ISSUES" | jq -r '.items[0].status')" "closed" issues.status "closed issue reads as closed"
PRS="$(M git prs "$ADDR" --relay "$RELAY_URL")"
assert_eq "$(echo "$PRS" | jq -r '.items[0].status')" "applied" prs.status "applied pr reads as applied"
PATCHES="$(M git patches "$ADDR" --relay "$RELAY_URL")"
assert_eq "$(echo "$PATCHES" | jq -r '.items[0].status')" "open" patches.status "un-statused patch reads as open"
# --status filter: closed issues present, open issues empty.
FILTERED="$(M git issues "$ADDR" --closed --relay "$RELAY_URL")"
assert_eq "$(echo "$FILTERED" | jq -r '.count')" "1" issues.filter_closed "--closed keeps the closed issue"
OPEN_ONLY="$(M git issues "$ADDR" --open --relay "$RELAY_URL")"
assert_eq "$(echo "$OPEN_ONLY" | jq -r '.count')" "0" issues.filter_open "--open drops the closed issue"
# =============================================================================
# Thread view
# =============================================================================
banner "thread view"
THREAD="$(M git thread "$ISSID" --relay "$RELAY_URL")"
info "thread: $THREAD"
assert_eq "$(echo "$THREAD" | jq -r '.status')" "closed" thread.status "thread shows closed"
assert_eq "$(echo "$THREAD" | jq -r '.status_events | length')" "1" thread.status_events "one status event in timeline"
assert_eq "$(echo "$THREAD" | jq -r '.comments | length')" "1" thread.comments "one comment in thread"
# =============================================================================
# Read repo content over git smart-HTTP (--live only — needs a reachable host).
# =============================================================================
if [[ "$LIVE" -eq 1 ]]; then
banner "live: git browse / cat / log ($LIVE_REPO)"
BROWSE="$(M git browse "$LIVE_REPO" --json)"
info "browse: $BROWSE"
assert_nonempty "$(echo "$BROWSE" | jq -r '.head_commit')" live.browse "browse resolves a head commit"
FIRST_FILE="$(echo "$BROWSE" | jq -r '.entries[] | select(.type=="file") | .name' | head -1)"
if [[ -n "$FIRST_FILE" ]]; then
CAT="$(M git cat "$LIVE_REPO" "$FIRST_FILE" --json)"
assert_eq "$(echo "$CAT" | jq -r '.path')" "$FIRST_FILE" live.cat "cat returns the requested file"
assert_nonempty "$(echo "$CAT" | jq -r '.oid')" live.cat_oid "cat resolves a blob oid"
fi
LOG="$(M git log "$LIVE_REPO" --depth 2 --json)"
assert_nonempty "$(echo "$LOG" | jq -r '.commits[0].oid')" live.log "log returns at least one commit"
# ngit interop against the REAL amethyst repo (published by ngit to relay.ngit.dev).
# Proves our reader parses ngit's actual multi-value `clone` tag (4 URLs) — the
# exact case the pre-fix reader collapsed to one.
banner "live: reading the real ngit-published amethyst repo"
NGIT_RELAY="${NGIT_RELAY:-wss://relay.ngit.dev}"
NGIT_REPO="30617:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:amethyst"
RSHOW="$(M git show "$NGIT_REPO" --relay "$NGIT_RELAY")"
if [[ -n "$RSHOW" && "$(echo "$RSHOW" | jq -r '.name // empty')" == "amethyst" ]]; then
CLONES="$(echo "$RSHOW" | jq -r '.clone | length')"
if [[ "$CLONES" -ge 2 ]]; then
pass_msg "ngit.clone_multivalue: read $CLONES clone URLs from ngit's multi-value tag"
record_result ngit.clone_multivalue pass "$CLONES clone URLs"
else
fail_msg "ngit.clone_multivalue: only $CLONES clone URL parsed from ngit's multi-value tag"
record_result ngit.clone_multivalue fail "expected >=2, got $CLONES"
fi
RISS="$(M git issues "$NGIT_REPO" --relay "$NGIT_RELAY" --limit 1 | jq -r '.count')"
assert_nonempty "$RISS" ngit.issues "read ngit-published issues"
else
skip_msg "ngit live repo unreachable (relay.ngit.dev) — skipping real-repo interop check"
fi
else
skip_msg "live git smart-HTTP reads (browse/cat/log) + real ngit repo — pass --live to run"
fi
grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1
exit 0

View File

@@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.action_dismiss
import com.vitorpamplona.amethyst.commons.resources.action_try_again
import com.vitorpamplona.amethyst.commons.resources.chats_history_all_caught_up
import com.vitorpamplona.amethyst.commons.resources.chats_history_by_relay
import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete
@@ -377,14 +378,32 @@ private fun relayShortName(relay: NormalizedRelayUrl): String =
fun RelayReachDetailDialog(
cursors: List<RelayReachCursor>,
formatReachDate: (epochSeconds: Long) -> String,
// When provided and at least one relay is stalled, the dialog offers a "Try Again" action that
// re-advances the stalled relays on demand (and suppresses the "retries on reopen" hint, since the
// caller retries actively). Null keeps the passive, dismiss-only dialog (the DM callers).
onRetry: (() -> Unit)? = null,
onDismiss: () -> Unit,
) {
val rows = remember(cursors) { cursors.sortedBy { it.reachedUntil } }
val showRetry = onRetry != null && rows.any { it.state == RelayReachState.STALLED }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) }
if (showRetry) {
TextButton(onClick = {
onRetry?.invoke()
onDismiss()
}) { Text(stringResource(Res.string.action_try_again)) }
} else {
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) }
}
},
dismissButton =
if (showRetry) {
{ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } }
} else {
null
},
title = { Text(stringResource(Res.string.chats_history_by_relay)) },
text = {
Column(
@@ -425,7 +444,9 @@ fun RelayReachDetailDialog(
maxLines = 1,
)
}
if (c.state == RelayReachState.STALLED) StalledRetryHint()
// The passive "retries on reopen" caption only applies without an active Try Again
// action; with one, the button (and the caller's auto-retry) covers it.
if (c.state == RelayReachState.STALLED && onRetry == null) StalledRetryHint()
}
}
}

View File

@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip14Subject.SubjectTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
@@ -130,6 +131,9 @@ class GitIssueEvent(
) = eventTemplate(KIND, content, createdAt) {
subject(subject)
repository(repository)
// NIP-34 issues carry the repository owner as a `p` tag so maintainers
// watching `#p` are notified; the same tag patches/PRs already include.
pTag(repository.event.pubKey, repository.authorHomeRelay)
notify(notify)
hashtags(topics)

View File

@@ -38,12 +38,12 @@ fun TagArrayBuilder<GitPatchEvent>.repository(rep: EventHintBundle<GitRepository
/**
* Adds the earliest-unique-commit `r` tag used by patches to point at the
* target repository. NIP-34 uses the plain `["r", <commit>]` shape for
* patches (unlike the repository announcement which marks the tag with
* `euc`). We also emit the marked form so the same event can be matched by
* implementations that look for the marker.
* target repository. NIP-34 uses the plain `["r", <commit>]` shape for patches
* — the `"euc"` marker appears only on the kind-30617 repository announcement
* (confirmed against the ngit reference implementation). A `#r` filter still
* matches either shape (both key off the value at index 1).
*/
fun TagArrayBuilder<GitPatchEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
fun TagArrayBuilder<GitPatchEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPatchEvent>.commit(commit: String) = addUnique(CommitTag.assemble(commit))

View File

@@ -87,7 +87,9 @@ class GitPullRequestEvent(
fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse)
fun cloneUrls(): List<String> = tags.mapNotNull(CloneTag::parse)
// Tolerant of both the NIP-34 spec form (one multi-value `["clone", a, b]` tag,
// which ngit emits) and the legacy repeated form; deduped.
fun cloneUrls(): List<String> = tags.flatMap(CloneTag::parseAll).distinct()
fun subject(): String? = tags.firstNotNullOfOrNull(SubjectTag::parse)
@@ -138,7 +140,7 @@ class GitPullRequestEvent(
pTag(repository.event.pubKey, repository.authorHomeRelay)
if (notify.isNotEmpty()) pTags(notify)
currentCommit(currentCommit)
cloneUrls.forEach { cloneUrl(it) }
if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls)
subject?.let { subject(it) }
if (labels.isNotEmpty()) hashtags(labels)
branchName?.let { branchName(it) }

View File

@@ -81,7 +81,8 @@ class GitPullRequestUpdateEvent(
fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse)
fun cloneUrls(): List<String> = tags.mapNotNull(CloneTag::parse)
// Tolerant of both the multi-value and legacy repeated `clone` forms; deduped.
fun cloneUrls(): List<String> = tags.flatMap(CloneTag::parseAll).distinct()
fun earliestUniqueCommit(): String? = tags.firstOrNull { it.size > 1 && it[0] == "r" && it[1].isNotEmpty() }?.get(1)
@@ -119,7 +120,7 @@ class GitPullRequestUpdateEvent(
pTag(repository.event.pubKey, repository.authorHomeRelay)
if (notify.isNotEmpty()) pTags(notify)
currentCommit(currentCommit)
cloneUrls.forEach { cloneUrl(it) }
if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls)
mergeBase?.let { mergeBase(it) }
initializer()
}

View File

@@ -38,12 +38,17 @@ fun TagArrayBuilder<GitPullRequestEvent>.repository(rep: ATag) = addUnique(rep.t
fun TagArrayBuilder<GitPullRequestEvent>.repository(rep: EventHintBundle<GitRepositoryEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<GitPullRequestEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
// NIP-34 pull requests use the plain `["r", <commit>]` shape; the `"euc"` marker
// is only on the kind-30617 announcement (see the patch builder for the rationale).
fun TagArrayBuilder<GitPullRequestEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPullRequestEvent>.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit))
fun TagArrayBuilder<GitPullRequestEvent>.cloneUrl(url: String) = add(CloneTag.assemble(url))
/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitPullRequestEvent>.cloneUrls(urls: List<String>) = addUnique(CloneTag.assemble(urls))
fun TagArrayBuilder<GitPullRequestEvent>.subject(subject: String) = addUnique(SubjectTag.assemble(subject))
fun TagArrayBuilder<GitPullRequestEvent>.branchName(name: String) = addUnique(BranchNameTag.assemble(name))

View File

@@ -36,12 +36,16 @@ fun TagArrayBuilder<GitPullRequestUpdateEvent>.repository(rep: ATag) = addUnique
fun TagArrayBuilder<GitPullRequestUpdateEvent>.repository(rep: EventHintBundle<GitRepositoryEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<GitPullRequestUpdateEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
// Plain `["r", <commit>]` — the `"euc"` marker is only on the 30617 announcement.
fun TagArrayBuilder<GitPullRequestUpdateEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.cloneUrl(url: String) = add(CloneTag.assemble(url))
/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitPullRequestUpdateEvent>.cloneUrls(urls: List<String>) = addUnique(CloneTag.assemble(urls))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.mergeBase(commit: String) = addUnique(MergeBaseTag.assemble(commit))
/** Adds the NIP-22 `E` tag pointing at the parent Pull Request. */

View File

@@ -58,14 +58,21 @@ class GitRepositoryEvent(
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
/** First web URL, for backwards compatibility. Prefer [webs]. */
fun web() = tags.firstNotNullOfOrNull(WebTag::parse)
fun web() = webs().firstOrNull()
fun webs(): List<String> = tags.mapNotNull(WebTag::parse)
/**
* All browse URLs. Tolerant of both the NIP-34 spec form (one multi-value
* `["web", url1, url2]` tag, which ngit emits) and the legacy repeated
* `["web", url1]` / `["web", url2]` form, so a repo announced by any client
* round-trips without dropping URLs.
*/
fun webs(): List<String> = tags.flatMap(WebTag::parseAll).distinct()
/** First clone URL, for backwards compatibility. Prefer [clones]. */
fun clone() = tags.firstNotNullOfOrNull(CloneTag::parse)
fun clone() = clones().firstOrNull()
fun clones(): List<String> = tags.mapNotNull(CloneTag::parse)
/** All clone URLs — tolerant of both the multi-value and repeated forms (see [webs]); deduped. */
fun clones(): List<String> = tags.flatMap(CloneTag::parseAll).distinct()
/**
* Relays the repository author monitors for patches and issues. NIP-34
@@ -131,8 +138,8 @@ class GitRepositoryEvent(
dTag(dTag)
name(name)
description?.let { description(it) }
webUrls.forEach { webUrl(it) }
cloneUrls.forEach { cloneUrl(it) }
if (webUrls.isNotEmpty()) webUrls(webUrls)
if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls)
if (relays.isNotEmpty()) relays(relays)
if (maintainers.isNotEmpty()) maintainers(maintainers)
if (hashtags.isNotEmpty()) hashtags(hashtags)

View File

@@ -36,11 +36,13 @@ fun TagArrayBuilder<GitRepositoryEvent>.description(description: String) = addUn
fun TagArrayBuilder<GitRepositoryEvent>.webUrl(webUrl: String) = add(WebTag.assemble(webUrl))
fun TagArrayBuilder<GitRepositoryEvent>.webUrls(webUrls: List<String>) = addAll(webUrls.map(WebTag::assemble))
/** Emit all browse URLs as one NIP-34 multi-value `["web", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitRepositoryEvent>.webUrls(webUrls: List<String>) = addUnique(WebTag.assemble(webUrls))
fun TagArrayBuilder<GitRepositoryEvent>.cloneUrl(cloneUrl: String) = add(CloneTag.assemble(cloneUrl))
fun TagArrayBuilder<GitRepositoryEvent>.cloneUrls(cloneUrls: List<String>) = addAll(cloneUrls.map(CloneTag::assemble))
/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitRepositoryEvent>.cloneUrls(cloneUrls: List<String>) = addUnique(CloneTag.assemble(cloneUrls))
fun TagArrayBuilder<GitRepositoryEvent>.relays(relays: List<String>) = addUnique(RelaysTag.assemble(relays))

View File

@@ -23,6 +23,13 @@ package com.vitorpamplona.quartz.nip34Git.repository.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-34 repository `clone` tag. The spec encodes clone URLs as a single
* multi-value tag — `["clone", "<url1>", "<url2>", ...]` — so [assemble] emits
* that form and [parseAll] reads every value. [parse] (first value only) and
* the legacy repeated `["clone", "<url>"]` form are still read for backward
* compatibility (see `GitRepositoryEvent.clones`).
*/
class CloneTag {
companion object {
const val TAG_NAME = "clone"
@@ -34,6 +41,16 @@ class CloneTag {
return tag[1]
}
/** Every non-empty URL carried by a single `clone` tag. */
fun parseAll(tag: Array<String>): List<String> {
ensure(tag.has(1)) { return emptyList() }
ensure(tag[0] == TAG_NAME) { return emptyList() }
return tag.drop(1).filter { it.isNotEmpty() }
}
fun assemble(name: String) = arrayOf(TAG_NAME, name)
/** The spec form: one tag carrying all clone URLs. */
fun assemble(urls: List<String>): Array<String> = (listOf(TAG_NAME) + urls.filter { it.isNotEmpty() }).toTypedArray()
}
}

View File

@@ -23,6 +23,12 @@ package com.vitorpamplona.quartz.nip34Git.repository.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-34 repository `web` tag. Like `clone`, the spec encodes browse URLs as a
* single multi-value tag — `["web", "<url1>", "<url2>", ...]` — so [assemble]
* emits that form and [parseAll] reads every value. [parse] (first value only)
* and the legacy repeated form are still read for backward compatibility.
*/
class WebTag {
companion object {
const val TAG_NAME = "web"
@@ -34,6 +40,16 @@ class WebTag {
return tag[1]
}
/** Every non-empty URL carried by a single `web` tag. */
fun parseAll(tag: Array<String>): List<String> {
ensure(tag.has(1)) { return emptyList() }
ensure(tag[0] == TAG_NAME) { return emptyList() }
return tag.drop(1).filter { it.isNotEmpty() }
}
fun assemble(name: String) = arrayOf(TAG_NAME, name)
/** The spec form: one tag carrying all browse URLs. */
fun assemble(urls: List<String>): Array<String> = (listOf(TAG_NAME) + urls.filter { it.isNotEmpty() }).toTypedArray()
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip34Git
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Locks in byte-level interoperability with ngit (github.com/DanConwayDev/ngit-cli)
* and the NIP-34 spec for the tag shapes that previously diverged:
*
* 1. `clone` / `web` are ONE multi-value tag, not repeated single-value tags —
* ngit's parser keeps only the last of repeated known tags, so the repeated
* form silently dropped URLs across implementations.
* 2. Issues carry the repository owner as a `p` tag (maintainer routing).
* 3. Patch/PR `r` tags are plain `["r", <commit>]`; the `"euc"` marker lives
* only on the kind-30617 announcement.
*/
class GitNip34InteropTest {
private val owner = "aa".repeat(32)
private fun repo(tags: Array<Array<String>>) = GitRepositoryEvent("00", owner, 0, tags, "", "00")
@Test
fun announcementEmitsSingleMultiValueCloneAndWeb() {
val tmpl =
GitRepositoryEvent.build(
name = "demo",
description = null,
webUrls = listOf("https://a.com", "https://b.com"),
cloneUrls = listOf("https://a.git", "https://b.git"),
relays = emptyList(),
maintainers = emptyList(),
hashtags = emptyList(),
earliestUniqueCommit = null,
dTag = "demo",
)
assertEquals(1, tmpl.tags.count { it[0] == "clone" }, "clone must be a single tag")
assertEquals(1, tmpl.tags.count { it[0] == "web" }, "web must be a single tag")
assertEquals(listOf("clone", "https://a.git", "https://b.git"), tmpl.tags.first { it[0] == "clone" }.toList())
assertEquals(listOf("web", "https://a.com", "https://b.com"), tmpl.tags.first { it[0] == "web" }.toList())
}
@Test
fun readsSpecMultiValueForm() {
val r =
repo(
arrayOf(
arrayOf("d", "x"),
arrayOf("clone", "https://a.git", "https://b.git"),
arrayOf("web", "https://a.com", "https://b.com"),
),
)
assertEquals(listOf("https://a.git", "https://b.git"), r.clones())
assertEquals(listOf("https://a.com", "https://b.com"), r.webs())
}
@Test
fun readsLegacyRepeatedForm() {
val r =
repo(
arrayOf(
arrayOf("d", "x"),
arrayOf("clone", "https://a.git"),
arrayOf("clone", "https://b.git"),
arrayOf("web", "https://a.com"),
arrayOf("web", "https://b.com"),
),
)
assertEquals(listOf("https://a.git", "https://b.git"), r.clones())
assertEquals(listOf("https://a.com", "https://b.com"), r.webs())
}
@Test
fun issueCarriesRepositoryOwnerPTag() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("name", "x")))
val tmpl = GitIssueEvent.build("subject", "body", EventHintBundle(repoEvent), emptyList(), emptyList())
val pTags = tmpl.tags.filter { it[0] == "p" }.map { it[1] }
assertTrue(owner in pTags, "issue must p-tag the repository owner for maintainer routing")
}
@Test
fun pullRequestEmitsAndReadsMultiValueClone() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("r", "root", "euc")))
val tmpl =
GitPullRequestEvent.build(
description = "desc",
repository = EventHintBundle(repoEvent),
earliestUniqueCommit = "root",
currentCommit = "tip",
cloneUrls = listOf("https://a.git", "https://b.git"),
)
// One multi-value clone tag (ngit/spec form), not repeated single-value tags.
assertEquals(1, tmpl.tags.count { it[0] == "clone" }, "PR clone must be a single tag")
assertEquals(listOf("clone", "https://a.git", "https://b.git"), tmpl.tags.first { it[0] == "clone" }.toList())
// Reader flattens both the multi-value and the legacy repeated forms.
val multi = GitPullRequestEvent("00", owner, 0, arrayOf(arrayOf("clone", "https://a.git", "https://b.git")), "", "00")
val repeated = GitPullRequestEvent("00", owner, 0, arrayOf(arrayOf("clone", "https://a.git"), arrayOf("clone", "https://b.git")), "", "00")
assertEquals(listOf("https://a.git", "https://b.git"), multi.cloneUrls())
assertEquals(listOf("https://a.git", "https://b.git"), repeated.cloneUrls())
}
@Test
fun patchRTagIsPlainWithoutEucMarker() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("r", "rootcommit", "euc")))
val tmpl =
GitPatchEvent.build(
patch = "diff",
repository = EventHintBundle(repoEvent),
earliestUniqueCommit = "rootcommit",
commit = "c1",
)
assertEquals(listOf("r", "rootcommit"), tmpl.tags.first { it[0] == "r" }.toList(), "patch r tag must be plain (no euc marker)")
}
}