mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
20 Commits
ef9a891974
...
5e5ecdab8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5ecdab8b | ||
|
|
062ab0f012 | ||
|
|
5f5356c0fe | ||
|
|
4e0ac212ee | ||
|
|
63d01c9287 | ||
|
|
fa32ea50a3 | ||
|
|
1a77c95531 | ||
|
|
d3e208322c | ||
|
|
b06184ac49 | ||
|
|
45d33c016e | ||
|
|
9922eef9f7 | ||
|
|
ad66e6c224 | ||
|
|
da451145af | ||
|
|
a5ac06ad6c | ||
|
|
77908b2be0 | ||
|
|
05426d8c66 | ||
|
|
c8f43a1bd4 | ||
|
|
cc94ef9103 | ||
|
|
ad75344c3e | ||
|
|
ba8de9ba21 |
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (1–2 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 1–2 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('/')
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
1
cli/tests/.gitignore
vendored
1
cli/tests/.gitignore
vendored
@@ -6,3 +6,4 @@ clink/state-clink-headless/
|
||||
relaygroup/state-relaygroup-headless/
|
||||
sync/state-sync-deletions/
|
||||
blossom/state-blossom-live/
|
||||
git/state-git-nip34/
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
331
cli/tests/git/git-nip34-headless.sh
Executable file
331
cli/tests/git/git-nip34-headless.sh
Executable 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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user