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
This commit is contained in:
@@ -326,6 +326,8 @@ the default is the repo's advertised relays, else your outbox.
|
||||
| `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 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). |
|
||||
|
||||
**Issues, patches & pull requests**
|
||||
|
||||
|
||||
+1
-1
@@ -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` | ✅ (events) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633); `issues`/`patches`/`prs`/`thread` reads derive status. clone/push (git-packfile transport) out of scope. |
|
||||
| `git` | `amy git` | ✅ (events) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status. clone/push (git-packfile transport) out of scope. |
|
||||
| `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`. |
|
||||
|
||||
@@ -632,6 +632,7 @@ private fun printUsage() {
|
||||
| [--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 grasp list [USER] | set URL[,URL] GRASP hosting-server list (kind 10317)
|
||||
| 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)
|
||||
|
||||
@@ -59,6 +59,7 @@ object GitCommands {
|
||||
| [--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)
|
||||
|
|
||||
|Issues / patches / pull requests:
|
||||
| git issue REPO --subject S [BODY] publish a kind:1621 issue (BODY arg or stdin)
|
||||
@@ -89,12 +90,13 @@ object GitCommands {
|
||||
route(
|
||||
"git",
|
||||
tail,
|
||||
"git <announce|state|list|show|issue|patch|pr|comment|open|applied|close|draft|issues|patches|prs|thread>",
|
||||
"git <announce|state|list|show|grasp|issue|patch|pr|comment|open|applied|close|draft|issues|patches|prs|thread>",
|
||||
mapOf(
|
||||
"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) },
|
||||
"issue" to { rest -> issue(dataDir, rest) },
|
||||
"issues" to { rest -> GitReadCommands.issues(dataDir, rest) },
|
||||
"patch" to { rest -> GitPatchCommands.patch(dataDir, rest) },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,13 @@ 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"
|
||||
|
||||
# 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)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user