mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
24 Commits
claude/not
...
078758888a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
078758888a | ||
|
|
387bfe99ee | ||
|
|
6033957b1c | ||
|
|
37923d9101 | ||
|
|
57ffb3386d | ||
|
|
a3e239d33f | ||
|
|
41240eeab9 | ||
|
|
5988db010d | ||
|
|
72e27a3530 | ||
|
|
556ef9f880 | ||
|
|
52c863a9c6 | ||
|
|
5e5ecdab8b | ||
|
|
062ab0f012 | ||
|
|
5f5356c0fe | ||
|
|
4e0ac212ee | ||
|
|
63d01c9287 | ||
|
|
fa32ea50a3 | ||
|
|
1a77c95531 | ||
|
|
d3e208322c | ||
|
|
b06184ac49 | ||
|
|
45d33c016e | ||
|
|
9922eef9f7 | ||
|
|
ad66e6c224 | ||
|
|
da451145af |
@@ -567,6 +567,7 @@ dependencies {
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -583,6 +584,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsImage> { ShareNoteAsImageScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsImageFile> { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsQr> { ShareNoteAsQrScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
|
||||
|
||||
@@ -507,6 +507,10 @@ sealed class Route {
|
||||
val id: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ShareNoteAsQr(
|
||||
val id: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ContactListUsers(
|
||||
val noteId: String,
|
||||
) : Route()
|
||||
|
||||
@@ -38,8 +38,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
* both from the reaction-row Share button and from the note's 3-dot menu).
|
||||
*
|
||||
* Only the true "send it somewhere" options live here — browser link, image
|
||||
* file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so
|
||||
* they are intentionally NOT part of this shared element.
|
||||
* file, image URL, and the display-only QR code. The copy-to-clipboard
|
||||
* options stay in the 3-dot menu, so they are intentionally NOT part of this
|
||||
* shared element.
|
||||
*
|
||||
* Callers only render these for non-private notes: every option exposes the
|
||||
* note publicly (a shareable web link, or an image of it), which must never
|
||||
@@ -76,4 +77,8 @@ fun ShareActionRows(
|
||||
nav.nav(Route.ShareNoteAsImage(shareId))
|
||||
onDismiss()
|
||||
}
|
||||
M3ActionRow(icon = MaterialSymbols.QrCode2, text = stringRes(R.string.share_as_qr)) {
|
||||
nav.nav(Route.ShareNoteAsQr(shareId))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.note.share
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
|
||||
|
||||
/** Which of the two payloads the QR code currently encodes. */
|
||||
enum class QrPayloadMode {
|
||||
/**
|
||||
* An `https://njump.to/…` link. The default, because a stock phone camera will offer to
|
||||
* open an http(s) URL but may treat a bare custom scheme as inert text.
|
||||
*/
|
||||
Web,
|
||||
|
||||
/**
|
||||
* A `nostr:nevent1…` / `nostr:naddr1…` URI. Resolves without a web round-trip and is what
|
||||
* in-app scanners expect.
|
||||
*/
|
||||
Nostr,
|
||||
}
|
||||
|
||||
/**
|
||||
* The string encoded into the QR code for [note] in [mode].
|
||||
*
|
||||
* Both payloads carry the note's relay hint: [externalLinkForNote] builds its URL from
|
||||
* `toNAddr()` / `toNEvent()`, which already call `relayHintUrl()`.
|
||||
*/
|
||||
fun qrPayloadFor(
|
||||
note: Note,
|
||||
mode: QrPayloadMode,
|
||||
): String =
|
||||
when (mode) {
|
||||
QrPayloadMode.Web -> externalLinkForNote(note)
|
||||
QrPayloadMode.Nostr -> note.toNostrUri()
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* 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.note.share
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a
|
||||
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
|
||||
// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable
|
||||
// resolution on low-density screens. Deriving the size from the available column width keeps
|
||||
// enough real pixels per module; this only bounds it from growing unreasonably large on tablets.
|
||||
private val QrMaxSize = 320.dp
|
||||
|
||||
/**
|
||||
* Display-only screen presenting [id]'s note as a scannable QR code.
|
||||
*
|
||||
* There is no export or save action by design — the screen exists to be held up and
|
||||
* photographed by another device.
|
||||
*
|
||||
* F6: the Scaffold (and its back button) lives in this id-based wrapper, OUTSIDE LoadNote's
|
||||
* null check, so an id that never resolves to a note still leaves the user a way back — only
|
||||
* the body inside is empty in that case. Rendering nothing else for an unresolved id is
|
||||
* deliberate, matching ShareNoteAsImageScreen; only the missing chrome was the bug.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShareNoteAsQrScreen(
|
||||
id: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
|
||||
) { pad ->
|
||||
LoadNote(id, accountViewModel) { note ->
|
||||
if (note != null) {
|
||||
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShareNoteAsQrScreen(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
|
||||
) { pad ->
|
||||
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ShareNoteAsQrScreenContent(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
pad: PaddingValues,
|
||||
) {
|
||||
var mode by remember { mutableStateOf(QrPayloadMode.Web) }
|
||||
|
||||
// F4: keyed on the observed note state, not on `note` alone (a stable object identity that
|
||||
// does not change while the event and the author's relay list are still loading). A payload
|
||||
// computed before then would omit the relay hint (Note.relayHintUrl()) and never recompute.
|
||||
// Keying on `noteState` re-derives the payload once the event arrives — the same observation
|
||||
// SharedNoteCard uses for its own sensitivity gate (F1).
|
||||
val noteState by observeNote(note, accountViewModel)
|
||||
val payload = remember(noteState, mode) { qrPayloadFor(note, mode) }
|
||||
|
||||
KeepScreenBrightAndAwake()
|
||||
|
||||
// F5: scrollable so the toggle and hint — the screen's only controls — stay reachable on a
|
||||
// short viewport (landscape, split screen, large font scale) where the square QR plus the
|
||||
// card above it can otherwise exceed the available height. A plain fillMaxSize() Column would
|
||||
// silently place that overflow outside its bounds instead of clipping or scrolling to it.
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
SharedNoteCard(note, accountViewModel, nav)
|
||||
|
||||
val qrContentDescription =
|
||||
when (mode) {
|
||||
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_code_description_web)
|
||||
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_code_description_nostr)
|
||||
}
|
||||
QrCodeDrawer(
|
||||
contents = payload,
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = QrMaxSize)
|
||||
.fillMaxWidth()
|
||||
.semantics { contentDescription = qrContentDescription },
|
||||
)
|
||||
|
||||
// Fill the width so each button gets an equal, generous half (a bare
|
||||
// SingleChoiceSegmentedButtonRow shrinks to content and clips longer labels), and pass an
|
||||
// empty `icon` so the selected-state checkmark never steals horizontal room from the
|
||||
// label — selection is already signalled by the fill colour. Both matter for translated
|
||||
// labels, which are often longer than the English "Web link" / "Nostr link".
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
val modes = listOf(QrPayloadMode.Web, QrPayloadMode.Nostr)
|
||||
modes.forEachIndexed { index, candidate ->
|
||||
SegmentedButton(
|
||||
selected = mode == candidate,
|
||||
onClick = { mode = candidate },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = index, count = modes.size),
|
||||
icon = {},
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
when (candidate) {
|
||||
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_mode_web)
|
||||
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_mode_nostr)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text =
|
||||
when (mode) {
|
||||
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_hint_web)
|
||||
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_hint_nostr)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the screen to full brightness and prevents it sleeping while the QR is displayed,
|
||||
* restoring both on exit.
|
||||
*
|
||||
* This is functional, not polish: the screen exists to be photographed, and a dark-theme phone
|
||||
* on auto-brightness in a dim room is exactly the case that fails.
|
||||
*
|
||||
* Residual hazard, not reachable today: [LocalView.current] is the Activity's single shared root
|
||||
* `ComposeView`, not a view scoped to this screen. During a nav transition two compositions can
|
||||
* briefly coexist on that same root view, so this screen's `onDispose` could in theory clobber
|
||||
* brightness/`keepScreenOn` state an incoming screen has already set. Nothing in the current nav
|
||||
* graph triggers that overlap, so this is left as a comment rather than code.
|
||||
*/
|
||||
@Composable
|
||||
private fun KeepScreenBrightAndAwake() {
|
||||
val view = LocalView.current
|
||||
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
|
||||
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
|
||||
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
|
||||
// chain (WindowUtils.kt:39-46).
|
||||
val window = getActivityWindow()
|
||||
|
||||
DisposableEffect(window, view) {
|
||||
// Capture the RAW attribute, not a computed fraction. When no override is set this is
|
||||
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
|
||||
// brightness. Restoring a *computed* fraction would install an override where none
|
||||
// existed and silently disable auto-brightness for the rest of the session.
|
||||
val previousBrightness = window?.attributes?.screenBrightness
|
||||
|
||||
// F8: same capture/replay discipline as brightness above, and for the same reason.
|
||||
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
|
||||
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
|
||||
// Hard-setting `false` on dispose — instead of restoring what was here before this
|
||||
// screen took it over — would clobber that ownership: navigating back from the QR
|
||||
// screen while audio or video is still playing would let the screen sleep mid-playback.
|
||||
val previousKeepScreenOn = view.keepScreenOn
|
||||
|
||||
window?.let {
|
||||
it.attributes = it.attributes.apply { screenBrightness = 1f }
|
||||
}
|
||||
view.keepScreenOn = true
|
||||
|
||||
onDispose {
|
||||
// Restore the captured value rather than calling a release helper: resetting to
|
||||
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
|
||||
// already had, e.g. one left by the fullscreen video controls.
|
||||
window?.let { w ->
|
||||
previousBrightness?.let { prev ->
|
||||
w.attributes = w.attributes.apply { screenBrightness = prev }
|
||||
}
|
||||
}
|
||||
view.keepScreenOn = previousKeepScreenOn
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* 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.note.share
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThumbnail
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.alt
|
||||
import com.vitorpamplona.quartz.nip71Video.blurhash
|
||||
import com.vitorpamplona.quartz.nip71Video.mimeType
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
|
||||
private val CardHeight = 72.dp
|
||||
private val ThumbSize = 56.dp
|
||||
private val ThumbShape = RoundedCornerShape(9.dp)
|
||||
|
||||
/**
|
||||
* A fixed-height preview of the note being shared, shown above the QR code.
|
||||
*
|
||||
* The height is fixed on purpose: it keeps the QR code in the same screen position for every
|
||||
* note, so the screen is predictable to hold up to a scanner. That is why this does not use
|
||||
* [com.vitorpamplona.amethyst.ui.note.NoteCompose] — see the design spec for the full reasoning,
|
||||
* but in short, `isQuotedNote` never reaches the media renderer and note images render at their
|
||||
* natural aspect ratio with no height ceiling.
|
||||
*/
|
||||
@Composable
|
||||
fun SharedNoteCard(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().height(CardHeight).padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
// Plain SensitivityWarning is NOT enough here: it gates on event.isSensitiveOrNSFW(),
|
||||
// which only reads the note-level content-warning tag / nsfw hashtag. GalleryThumbnail's
|
||||
// inner gate (GalleryThumb.kt:236) reads each media entry's per-imeta `contentWarning`,
|
||||
// but none of GalleryThumb.kt's four media-construction sites ever set that field, so it
|
||||
// is always null and that inner gate is permanently dead on this path. A note whose only
|
||||
// warning lives inside an `imeta` tag (e.g. a kind 20 PictureEvent) would then render
|
||||
// completely unblurred. `hasImetaContentWarning` below checks every imeta for the mere
|
||||
// PRESENCE of a `content-warning` key, not for non-blank reason text: an imeta warning
|
||||
// with an empty reason string (`["content-warning"]` with no second element) still counts
|
||||
// — collectContentWarningReasons()'s `takeIf { it.isNotBlank() }` would silently drop that
|
||||
// exact case, which is what let it slip the gate before. collectContentWarningReasons()
|
||||
// is still called for the human-readable *reason text*, shown in the covered box's
|
||||
// accessibility label when one exists — it never drives the show/hide decision.
|
||||
//
|
||||
// `note.event` is a plain field read that never recomposes if this composable is
|
||||
// rendered before the note's event has arrived over the relay (id-only reference, e.g.
|
||||
// straight off a deep link). observeNote() subscribes both the relay finder and the
|
||||
// LocalCache flow, so `event` below updates — and this whole gate recomputes — the
|
||||
// moment the event loads or changes, the same idiom GalleryThumbnail itself already uses
|
||||
// (GalleryThumb.kt:78).
|
||||
//
|
||||
// This deliberately does NOT use the shared ContentWarningGate: its overlay
|
||||
// (ContentWarningOverlayBody, SensitivityWarning.kt:254+) opens with an 80.dp icon box
|
||||
// that consumes this card's entire 56.dp thumbnail height, pushing the title, reasons,
|
||||
// and "Show anyway" button below the clipped, tappable area. Reshaping that shared
|
||||
// composable was rejected — six other screens depend on its current layout — so this
|
||||
// call site renders its own compact, permanently-covered state instead: no reveal
|
||||
// affordance, because this screen is held up in public and pointed at someone else's
|
||||
// camera. `accountViewModel.showSensitiveContent()` is still honoured exactly as
|
||||
// ContentWarningGate honours it (SensitivityWarning.kt:138-140), so a user who has
|
||||
// opted into seeing sensitive content globally sees the real thumbnail here too. Either
|
||||
// way the thumbnail box stays a fixed 56.dp, keeping this Row's height fixed at 72.dp.
|
||||
//
|
||||
// `nav` is passed because GalleryThumbnail's signature demands it, but it is unused
|
||||
// there (GalleryThumb.kt:76) — navigation comes from ClickableNote at its other call
|
||||
// site. This card is not tappable.
|
||||
val noteState by observeNote(note, accountViewModel)
|
||||
val event = noteState.note.event
|
||||
val reasons = remember(event) { event?.let { collectContentWarningReasons(it) } ?: emptySet() }
|
||||
val hasImetaContentWarning =
|
||||
remember(event) {
|
||||
event?.imetas()?.any { it.properties.containsKey(ContentWarningTag.TAG_NAME) } ?: false
|
||||
}
|
||||
val isSensitive =
|
||||
remember(event, hasImetaContentWarning) {
|
||||
event != null && (event.isSensitiveOrNSFW() || hasImetaContentWarning)
|
||||
}
|
||||
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
|
||||
val isGated = isSensitive && showSensitiveContent != true
|
||||
|
||||
// Thumbnail source, in priority order:
|
||||
// 1. structured media event (kind 20/21/22, gallery, live clip) -> GalleryThumbnail;
|
||||
// 2. an image carried in an `imeta` tag of an otherwise-unstructured note (a kind 1
|
||||
// image post — content is typically just the media URL) -> render that image;
|
||||
// 3. no media at all -> the author's round AVATAR, which (unlike GalleryThumbnail's own
|
||||
// DisplayGalleryAuthorBanner fallback, a banner crop) cannot be mistaken for the
|
||||
// note's own picture (F7).
|
||||
// hasStructuredMedia() mirrors GalleryThumbnail's own per-kind checks (GalleryThumb.kt:
|
||||
// 82-186); contentImage covers the case GalleryThumbnail does NOT handle — a bare image
|
||||
// URL in an imeta on a kind 1 — so those posts show the picture instead of avatar + URL.
|
||||
val hasStructuredMedia = remember(event) { event != null && hasStructuredMedia(event) }
|
||||
val contentImage = remember(event) { event?.let { firstContentImage(it) } }
|
||||
|
||||
Box(Modifier.size(ThumbSize).clip(ThumbShape)) {
|
||||
if (isGated) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription =
|
||||
reasons.firstOrNull()?.let { stringRes(R.string.content_warning_with_reason, it) }
|
||||
?: stringRes(R.string.share_as_qr_thumbnail_hidden_sensitive),
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else if (hasStructuredMedia) {
|
||||
GalleryThumbnail(note, accountViewModel, nav)
|
||||
} else if (contentImage != null) {
|
||||
// UrlImageView crops to fill (ContentScale.Crop) and honours the account's
|
||||
// show-images setting and blossom bridge on its own; the enclosing 56.dp Box
|
||||
// bounds it so the card height stays fixed. No extra SensitivityWarning: the
|
||||
// isGated branch above already covered the sensitive case.
|
||||
UrlImageView(contentImage, accountViewModel)
|
||||
} else {
|
||||
NoteAuthorPicture(note, ThumbSize, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = note.author?.toBestDisplayName() ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = secondaryLineFor(event, isGated, contentImage != null),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [event] carries the kind of structured media [GalleryThumbnail] would render (a
|
||||
* profile-gallery entry, kind 20 picture, kind 21/22 video, or live-activity clip with a video
|
||||
* URL) — used only to pick between the media thumbnail and the author-avatar fallback (F7).
|
||||
* Mirrors GalleryThumbnail's own when-branch conditions (GalleryThumb.kt:82-186) rather than
|
||||
* duplicating its full [com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent] construction.
|
||||
*/
|
||||
private fun hasStructuredMedia(event: Event): Boolean =
|
||||
when (event) {
|
||||
is ProfileGalleryEntryEvent -> event.urls().isNotEmpty()
|
||||
is PictureEvent -> event.imetaTags().isNotEmpty()
|
||||
is VideoEvent -> event.imetaTags().isNotEmpty()
|
||||
is LiveActivitiesClipEvent -> event.videoUrl() != null
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** True when this `imeta` describes an image — by declared mime type, or failing that its URL. */
|
||||
internal fun IMetaTag.isImage(): Boolean {
|
||||
val mime = mimeType()?.firstOrNull()
|
||||
return if (mime != null) mime.startsWith("image/") else RichTextParser.isImageUrl(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* The first image carried in an [event]'s `imeta` tags, as a renderable [MediaUrlImage], or null
|
||||
* if the note has none. Covers the common kind-1 image post whose content is just a media URL —
|
||||
* a case [GalleryThumbnail] does not handle (its when-branches fall through to the author banner).
|
||||
* Only images are returned; a video-only imeta yields null and the card falls back to the avatar
|
||||
* rather than feeding a video URL to the image loader.
|
||||
*/
|
||||
internal fun firstContentImage(event: Event): MediaUrlImage? {
|
||||
val imeta = event.imetas().firstOrNull { it.isImage() } ?: return null
|
||||
return MediaUrlImage(
|
||||
url = imeta.url,
|
||||
description = imeta.alt()?.firstOrNull(),
|
||||
blurhash = imeta.blurhash()?.firstOrNull(),
|
||||
mimeType = imeta.mimeType()?.firstOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-or-two-line description under the author name: an article's title, else the note's own
|
||||
* text (with any embedded media URLs stripped), else the image's alt text, else a kind label.
|
||||
*
|
||||
* F3: when [isGated] (the same sensitive-and-not-opted-in decision computed for the thumbnail,
|
||||
* passed in rather than recomputed) title, content and alt text are all skipped in favor of the
|
||||
* neutral kind label — otherwise this line would render the sensitive note's own text unblurred
|
||||
* right next to the covered thumbnail, which is exactly what `Text.kt`'s `SensitivityWarning`
|
||||
* wrapping exists to prevent for note bodies elsewhere in the app. The author name stays visible
|
||||
* either way; only this line is affected.
|
||||
*
|
||||
* [hasContentImage] lets an image-only post whose text and alt are both empty fall back to a
|
||||
* "Picture" label rather than a blank line.
|
||||
*/
|
||||
@Composable
|
||||
private fun secondaryLineFor(
|
||||
event: Event?,
|
||||
isGated: Boolean,
|
||||
hasContentImage: Boolean,
|
||||
): String {
|
||||
if (event == null) return ""
|
||||
|
||||
// F10: remembered against (event, isGated) so a long article body is not re-trimmed on every
|
||||
// recomposition — only when the underlying event or the gate decision actually changes.
|
||||
val bodyText = remember(event, isGated) { secondaryBodyTextFor(event, isGated) }
|
||||
if (bodyText != null) return bodyText
|
||||
|
||||
return when {
|
||||
event is PictureEvent -> stringRes(R.string.share_as_qr_kind_picture)
|
||||
event is VideoEvent -> stringRes(R.string.kind_video)
|
||||
event is LongTextNoteEvent -> stringRes(R.string.article)
|
||||
hasContentImage -> stringRes(R.string.share_as_qr_kind_picture)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
// Plain (non-@Composable) so it can be wrapped in `remember` — `stringRes` calls, which the
|
||||
// kind-label fallback needs, are not allowed inside a remember calculation lambda.
|
||||
internal fun secondaryBodyTextFor(
|
||||
event: Event,
|
||||
isGated: Boolean,
|
||||
): String? {
|
||||
// Gated: never surface the note's own title, content or alt text, only the kind label above.
|
||||
if (isGated) return null
|
||||
|
||||
if (event is LongTextNoteEvent) {
|
||||
val title = event.title()
|
||||
if (!title.isNullOrBlank()) return title
|
||||
}
|
||||
|
||||
// An image-only post's content is typically just the media URL(s). Strip any that appear
|
||||
// verbatim (only when the note actually has imeta media, so a plain article — no imeta — is
|
||||
// never scanned) so the line does not show a bare CDN URL. If nothing meaningful remains,
|
||||
// fall through to the alt text, then the kind label.
|
||||
val mediaUrls = event.imetas().map { it.url }
|
||||
val stripped =
|
||||
if (mediaUrls.isEmpty()) {
|
||||
event.content
|
||||
} else {
|
||||
mediaUrls.fold(event.content) { acc, url -> acc.replace(url, "") }
|
||||
}
|
||||
// F10: bounded prefix — this line only ever shows two lines of bodySmall text, so there is
|
||||
// no need to trim() a full long-form article body (potentially tens of KB) to get there.
|
||||
val content = stripped.take(MAX_SECONDARY_LINE_CHARS).trim()
|
||||
if (content.isNotEmpty()) return content
|
||||
|
||||
return event
|
||||
.imetas()
|
||||
.firstOrNull { it.isImage() }
|
||||
?.alt()
|
||||
?.firstOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private const val MAX_SECONDARY_LINE_CHARS = 280
|
||||
@@ -531,6 +531,15 @@
|
||||
<string name="share_as_image_url">Share as Image Url</string>
|
||||
<string name="share_as_image_generating">Generating preview…</string>
|
||||
<string name="share_as_image_watermark">Shared via Amethyst</string>
|
||||
<string name="share_as_qr">Share as QR</string>
|
||||
<string name="share_as_qr_mode_web">Web link</string>
|
||||
<string name="share_as_qr_mode_nostr">Nostr link</string>
|
||||
<string name="share_as_qr_hint_web">Scan with any phone camera</string>
|
||||
<string name="share_as_qr_hint_nostr">Scan with a Nostr app</string>
|
||||
<string name="share_as_qr_kind_picture">Picture</string>
|
||||
<string name="share_as_qr_code_description_web">QR code containing a web link to this note</string>
|
||||
<string name="share_as_qr_code_description_nostr">QR code containing a Nostr link to this note</string>
|
||||
<string name="share_as_qr_thumbnail_hidden_sensitive">Thumbnail hidden for sensitive content</string>
|
||||
<string name="quick_action_copy_user_id">Author ID</string>
|
||||
<string name="quick_action_copy_note_id">Note ID</string>
|
||||
<string name="quick_action_copy_text">Copy Text</string>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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.note.share
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class QrPayloadTest {
|
||||
private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
|
||||
private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
|
||||
|
||||
private suspend fun textNote(): Note {
|
||||
val event = TextNoteEvent.build("hello qr") {}.let { aliceSigner.sign(it) }
|
||||
val note = Note(event.id)
|
||||
note.event = event
|
||||
return note
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webMode_returnsAnHttpsNjumpLink() =
|
||||
runTest {
|
||||
val payload = qrPayloadFor(textNote(), QrPayloadMode.Web)
|
||||
|
||||
assertTrue(
|
||||
"web mode must produce an https URL so a stock phone camera can action it, got: $payload",
|
||||
payload.startsWith("https://"),
|
||||
)
|
||||
assertTrue(payload.contains("nevent1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nostrMode_returnsANostrUriWithNevent() =
|
||||
runTest {
|
||||
val payload = qrPayloadFor(textNote(), QrPayloadMode.Nostr)
|
||||
|
||||
assertTrue(payload.startsWith("nostr:nevent1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothModes_encodeTheSameNote() =
|
||||
runTest {
|
||||
val note = textNote()
|
||||
val web = qrPayloadFor(note, QrPayloadMode.Web)
|
||||
val nostr = qrPayloadFor(note, QrPayloadMode.Nostr)
|
||||
|
||||
// The bech32 body must be identical; only the wrapper differs.
|
||||
assertEquals(nostr.removePrefix("nostr:"), web.substringAfterLast('/'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addressableNote_nostrMode_yieldsNaddrNotNevent() =
|
||||
runTest {
|
||||
// build(description, title, summary, image, publishedAt, dTag, createdAt, init)
|
||||
// — `title` is a required positional; `dTag` must be named.
|
||||
// LongTextNoteEvent.kt:148-157.
|
||||
val event =
|
||||
LongTextNoteEvent
|
||||
.build("body", "My Article", dTag = "my-article") {}
|
||||
.let { aliceSigner.sign(it) }
|
||||
val note = AddressableNote(event.address())
|
||||
note.event = event
|
||||
|
||||
val payload = qrPayloadFor(note, QrPayloadMode.Nostr)
|
||||
|
||||
assertTrue(
|
||||
"AddressableNote must encode as naddr via the toNEvent() override, got: $payload",
|
||||
payload.startsWith("nostr:naddr1"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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.note.share
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SharedNoteCardTest {
|
||||
private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
|
||||
private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
|
||||
|
||||
private val blossomUrl = "https://blossom.primal.net/66332885d206714439e39e441b6539622a07c108"
|
||||
|
||||
private suspend fun kind1(
|
||||
content: String,
|
||||
imetaUrl: String? = null,
|
||||
mime: String? = null,
|
||||
alt: String? = null,
|
||||
): Event =
|
||||
TextNoteEvent
|
||||
.build(content) {
|
||||
if (imetaUrl != null) {
|
||||
imetas(
|
||||
listOf(
|
||||
IMetaTagBuilder(imetaUrl)
|
||||
.apply {
|
||||
mime?.let { add("m", it) }
|
||||
alt?.let { add("alt", it) }
|
||||
}.build(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}.let { aliceSigner.sign(it) }
|
||||
|
||||
// ---- firstContentImage ----
|
||||
|
||||
@Test
|
||||
fun imageImeta_isPickedAsTheThumbnailImage() =
|
||||
runTest {
|
||||
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg")
|
||||
|
||||
val image = firstContentImage(note)
|
||||
|
||||
assertEquals(blossomUrl, image?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainTextNote_hasNoContentImage() =
|
||||
runTest {
|
||||
val note = kind1(content = "just some words, no media")
|
||||
|
||||
assertNull(firstContentImage(note))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoImeta_isNotRenderedAsAnImage() =
|
||||
runTest {
|
||||
// A video-only imeta must not be fed to the image loader; the card falls back to the avatar.
|
||||
val note = kind1(content = "clip", imetaUrl = "https://cdn.example/v", mime = "video/mp4")
|
||||
|
||||
assertNull(firstContentImage(note))
|
||||
}
|
||||
|
||||
// ---- secondaryBodyTextFor ----
|
||||
|
||||
@Test
|
||||
fun imageOnlyPost_stripsTheBareUrlToNothing() =
|
||||
runTest {
|
||||
// content is exactly the media URL — the whole point of the fix: never show the raw CDN URL.
|
||||
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg")
|
||||
|
||||
assertNull(secondaryBodyTextFor(note, isGated = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageOnlyPostWithAlt_fallsBackToAltText() =
|
||||
runTest {
|
||||
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg", alt = "A sunset")
|
||||
|
||||
assertEquals("A sunset", secondaryBodyTextFor(note, isGated = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun textPlusImage_keepsTheTextWithoutTheUrl() =
|
||||
runTest {
|
||||
val note = kind1(content = "check this out $blossomUrl", imetaUrl = blossomUrl, mime = "image/jpeg")
|
||||
|
||||
assertEquals("check this out", secondaryBodyTextFor(note, isGated = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatedNote_yieldsNoBodyText() =
|
||||
runTest {
|
||||
val note = kind1(content = "something sensitive", imetaUrl = blossomUrl, mime = "image/jpeg", alt = "nsfw")
|
||||
|
||||
assertNull(secondaryBodyTextFor(note, isGated = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun article_prefersItsTitle() =
|
||||
runTest {
|
||||
val note =
|
||||
LongTextNoteEvent
|
||||
.build("the body", "My Article", dTag = "a1") {}
|
||||
.let { aliceSigner.sign(it) }
|
||||
|
||||
assertEquals("My Article", secondaryBodyTextFor(note, isGated = false))
|
||||
}
|
||||
|
||||
// ---- IMetaTag.isImage ----
|
||||
|
||||
@Test
|
||||
fun isImage_trueForImageMime_falseForVideoMime() =
|
||||
runTest {
|
||||
val img = IMetaTagBuilder("https://x/y").add("m", "image/png").build()
|
||||
val vid = IMetaTagBuilder("https://x/y").add("m", "video/mp4").build()
|
||||
|
||||
assertTrue(img.isImage())
|
||||
assertTrue(!vid.isImage())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isImage_fallsBackToUrlExtensionWhenNoMime() =
|
||||
runTest {
|
||||
val withExt = IMetaTagBuilder("https://x/photo.jpg").build()
|
||||
val extensionless = IMetaTagBuilder("https://blossom.example/deadbeef").build()
|
||||
|
||||
assertTrue(withExt.isImage())
|
||||
assertTrue(!extensionless.isImage())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
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.store.sqlite.DefaultIndexingStrategy
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -42,6 +43,18 @@ class LocalRelayStore(
|
||||
) : AutoCloseable {
|
||||
companion object {
|
||||
val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/")
|
||||
|
||||
/**
|
||||
* Client defaults plus the authors-without-kinds index: shared
|
||||
* ViewModels (`Nip65RelayListViewModel`, `PrivateOutboxRelayListViewModel`,
|
||||
* `VanishRequestsState`) replay `authors`-only filters against this
|
||||
* store, which full-scan without `(pubkey, created_at)` — the
|
||||
* `(kind, pubkey, …)` index can't serve them, pubkey is its second
|
||||
* column. A personal store is small, so the extra insert cost is
|
||||
* negligible; existing DBs get the index built on next open via
|
||||
* `ensureOptionalIndexes`.
|
||||
*/
|
||||
val INDEX_STRATEGY = DefaultIndexingStrategy(indexEventsByPubkeyAlone = true)
|
||||
}
|
||||
|
||||
private fun dbDir(pubKeyHex: String): File = File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}")
|
||||
@@ -87,14 +100,14 @@ class LocalRelayStore(
|
||||
dir.mkdirs()
|
||||
val path = File(dir, "events.db").absolutePath
|
||||
try {
|
||||
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
|
||||
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL, indexStrategy = INDEX_STRATEGY)
|
||||
_lastError.value = null
|
||||
refreshStats()
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalRelayStore") { "DB open failed, recreating: ${e.message}" }
|
||||
try {
|
||||
deleteDbFiles(path)
|
||||
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
|
||||
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL, indexStrategy = INDEX_STRATEGY)
|
||||
_lastError.value = "Database was recreated: ${e.message}"
|
||||
} catch (e2: Exception) {
|
||||
_lastError.value = "Cannot open local store: ${e2.message}"
|
||||
|
||||
@@ -57,6 +57,14 @@ fun relayIndexingStrategy(
|
||||
// index unconditionally; without it the filter walks the whole
|
||||
// time index.
|
||||
indexEventsByPubkeyAlone = true,
|
||||
// The tag ∩ author ∩ kind shape (DM rooms, reports-by-follows,
|
||||
// follows-scoped community feeds — 65 client assembler call sites)
|
||||
// otherwise reads every row for the tag/kind before filtering the
|
||||
// author. TagAuthorIndexBenchmark @ 1M events: 14.2 ms -> 0.66 ms
|
||||
// (~21x, growing with corpus size) with insert cost inside run noise
|
||||
// (49.0 vs 47.4 µs/event). Existing DBs build the index on next open
|
||||
// via ensureOptionalIndexes.
|
||||
indexTagsWithKindAndPubkey = true,
|
||||
indexFullTextSearch = fullTextSearch,
|
||||
// Tokenize off the commit path; NostrServer drives the catch-up
|
||||
// worker and search queries drain it first, so NIP-50 stays
|
||||
|
||||
@@ -83,6 +83,8 @@ val store = EventStore(
|
||||
|
||||
By default, all single-letter tags with values are indexed. Override `shouldIndex(kind, tag)` for custom behavior. More indexes = faster queries but larger database.
|
||||
|
||||
Flag flips are safe on existing databases: any flag-gated index the strategy wants but the on-disk schema lacks is created on the next open (idempotent `CREATE INDEX IF NOT EXISTS`, one-time build cost) — no schema version bump involved. Disabling a flag never drops an existing index.
|
||||
|
||||
`indexFullTextSearch` defaults to `true` and controls the NIP-50 full-text index (`event_fts`). Set it to `false` when search is served elsewhere (e.g. a Vespa backend, or a `SearchEventSource` as shown below): inserts skip the FTS tokenization cost, no `event_fts` table/trigger is created, and any filter carrying a non-empty `search` term returns no matches.
|
||||
|
||||
## Non-Storage Relays (search, redirector, computed)
|
||||
|
||||
@@ -94,6 +94,8 @@ kotlin {
|
||||
// Forward the negentropy-benchmark corpus size to the test JVM.
|
||||
System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) }
|
||||
System.getProperty("followBenchScale")?.let { systemProperty("followBenchScale", it) }
|
||||
System.getProperty("tagBenchScale")?.let { systemProperty("tagBenchScale", it) }
|
||||
System.getProperty("fsBenchScale")?.let { systemProperty("fsBenchScale", it) }
|
||||
// Opt-in JFR profiling of a benchmark run (-PnegProfile=/tmp/neg.jfr).
|
||||
(project.findProperty("negProfile") as? String)?.let {
|
||||
jvmArgs("-XX:+FlightRecorder", "-XX:StartFlightRecording=filename=$it,settings=profile,dumponexit=true")
|
||||
|
||||
@@ -25,6 +25,22 @@ class EoseMessage(
|
||||
) : Message {
|
||||
override fun label() = LABEL
|
||||
|
||||
/**
|
||||
* Wire form is `["EOSE","<subId>"]` — sent once per REQ, so it is on
|
||||
* the per-subscription floor. Splice it directly when [subId] needs no
|
||||
* escaping (the common case: client-chosen sub ids are short ASCII),
|
||||
* skipping the generic serializer's node tree. Byte-identical output;
|
||||
* any exotic subId falls back.
|
||||
*/
|
||||
override fun toJson(): String {
|
||||
if (!isEscapeFreeAscii(subId)) return super.toJson()
|
||||
return buildString(subId.length + 12) {
|
||||
append("[\"EOSE\",\"")
|
||||
append(subId)
|
||||
append("\"]")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LABEL = "EOSE"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,25 @@ class OkMessage(
|
||||
) : Message {
|
||||
override fun label() = LABEL
|
||||
|
||||
/**
|
||||
* Wire form is `["OK","<eventId>",<true|false>,"<message>"]` — sent
|
||||
* once per published EVENT. [eventId] is validated hex (always
|
||||
* escape-free); splice directly when [message] also needs no escaping,
|
||||
* which covers the empty-string success ack and the plain-ASCII
|
||||
* rejection reasons. Byte-identical output; a reason with quotes or
|
||||
* non-ASCII falls back to the generic serializer.
|
||||
*/
|
||||
override fun toJson(): String {
|
||||
if (!isEscapeFreeAscii(message)) return super.toJson()
|
||||
return buildString(eventId.length + message.length + 20) {
|
||||
append("[\"OK\",\"")
|
||||
append(eventId)
|
||||
append(if (success) "\",true,\"" else "\",false,\"")
|
||||
append(message)
|
||||
append("\"]")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LABEL = "OK"
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.nip01Core.relay.commands.toClient
|
||||
|
||||
/**
|
||||
* True when every char of [s] is printable ASCII (0x20–0x7e) and not a JSON
|
||||
* metacharacter (`"` / `\`) — i.e. exactly the bytes a JSON string encoder
|
||||
* would emit verbatim between the quotes. Frame builders use this to gate a
|
||||
* direct-`buildString` fast path against the generic serializer: when it holds
|
||||
* the spliced output is byte-identical, and any exotic value (control chars,
|
||||
* quotes, non-ASCII) falls back to the escaping serializer.
|
||||
*/
|
||||
internal fun isEscapeFreeAscii(s: String): Boolean {
|
||||
for (c in s) {
|
||||
if (c < ' ' || c > '~' || c == '"' || c == '\\') return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -22,6 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.collections.immutable.PersistentMap
|
||||
import kotlinx.collections.immutable.PersistentSet
|
||||
import kotlinx.collections.immutable.persistentHashMapOf
|
||||
import kotlinx.collections.immutable.persistentHashSetOf
|
||||
import kotlinx.collections.immutable.toPersistentHashSet
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
@@ -62,9 +67,10 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
* copy-on-write CAS loops, mirroring the
|
||||
* `nip86RelayManagement.server.BanStore` pattern. Reads in
|
||||
* [candidatesFor] and [forEach] are wait-free single-load atomic.
|
||||
* Writes (subscription register / unregister) copy the inner maps
|
||||
* — fine for this workload because writes are subscription-rate
|
||||
* (rare) while reads are event-rate (frequent).
|
||||
* Writes (subscription register / unregister) build the next snapshot
|
||||
* from persistent (HAMT) maps — O(keys × log S) with structural
|
||||
* sharing rather than a full O(S) copy of both maps, since on a relay
|
||||
* a write happens on every REQ open and close.
|
||||
*
|
||||
* ## What the index does NOT cover
|
||||
*
|
||||
@@ -109,14 +115,26 @@ class FilterIndex<S : Any> {
|
||||
private object Unindexed : BucketKey
|
||||
|
||||
/**
|
||||
* Single immutable snapshot. [buckets] maps a key to the set of
|
||||
* subscribers registered under it; [assignments] is the reverse
|
||||
* map used by [unregister] to find a subscriber's keys without
|
||||
* scanning every bucket.
|
||||
* Single immutable snapshot. Subscribers are held in one map per
|
||||
* indexable dimension so [candidatesFor] — called once per accepted
|
||||
* ingest event, the hot read — can probe each dimension with the
|
||||
* event's own field (`event.id`, `event.pubKey`, `tag[0]`/`tag[1]`,
|
||||
* `event.kind`) and allocate no key-wrapper objects. [assignments] is
|
||||
* the reverse map ([S] → the [BucketKey]s it occupies) used by
|
||||
* [unregister]; the wrappers live only here, built on the rare
|
||||
* register path.
|
||||
*
|
||||
* Persistent (HAMT) maps/sets: a register/unregister produces the
|
||||
* next snapshot in O(keys × log S) with structural sharing, instead
|
||||
* of copying full maps — registration happens on every REQ open/close.
|
||||
*/
|
||||
private data class State<S>(
|
||||
val buckets: Map<BucketKey, Set<S>> = emptyMap(),
|
||||
val assignments: Map<S, Set<BucketKey>> = emptyMap(),
|
||||
val ids: PersistentMap<HexKey, PersistentSet<S>> = persistentHashMapOf(),
|
||||
val authors: PersistentMap<HexKey, PersistentSet<S>> = persistentHashMapOf(),
|
||||
val tags: PersistentMap<String, PersistentMap<String, PersistentSet<S>>> = persistentHashMapOf(),
|
||||
val kinds: PersistentMap<Int, PersistentSet<S>> = persistentHashMapOf(),
|
||||
val unindexed: PersistentSet<S> = persistentHashSetOf(),
|
||||
val assignments: PersistentMap<S, PersistentSet<BucketKey>> = persistentHashMapOf(),
|
||||
)
|
||||
|
||||
private val state: AtomicReference<State<S>> = AtomicReference(State())
|
||||
@@ -181,18 +199,23 @@ class FilterIndex<S : Any> {
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
val keys = current.assignments[subscriber] ?: return
|
||||
val newBuckets = current.buckets.toMutableMap()
|
||||
var ids = current.ids
|
||||
var authors = current.authors
|
||||
var tags = current.tags
|
||||
var kinds = current.kinds
|
||||
var unindexed = current.unindexed
|
||||
for (key in keys) {
|
||||
val cur = newBuckets[key] ?: continue
|
||||
val next = cur - subscriber
|
||||
if (next.isEmpty()) {
|
||||
newBuckets.remove(key)
|
||||
} else {
|
||||
newBuckets[key] = next
|
||||
when (key) {
|
||||
is IdKey -> ids = ids.removeSub(key.id, subscriber)
|
||||
is AuthorKey -> authors = authors.removeSub(key.author, subscriber)
|
||||
is KindKey -> kinds = kinds.removeSub(key.kind, subscriber)
|
||||
is TagKey -> tags = tags.removeTagSub(key.letter, key.value, subscriber)
|
||||
Unindexed -> unindexed = unindexed.remove(subscriber)
|
||||
}
|
||||
}
|
||||
val newAssignments = current.assignments - subscriber
|
||||
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return
|
||||
val next =
|
||||
State(ids, authors, tags, kinds, unindexed, current.assignments.remove(subscriber))
|
||||
if (state.compareAndSet(current, next)) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,19 +225,22 @@ class FilterIndex<S : Any> {
|
||||
* candidate to handle negative constraints.
|
||||
*
|
||||
* Iteration order is insertion-stable per call but otherwise
|
||||
* unspecified.
|
||||
* unspecified. Allocates only the result set — dimensions are
|
||||
* probed with the event's own fields, no key wrappers.
|
||||
*/
|
||||
fun candidatesFor(event: Event): Set<S> {
|
||||
val s = state.load()
|
||||
if (s.buckets.isEmpty()) return emptySet()
|
||||
if (s.assignments.isEmpty()) return emptySet()
|
||||
val result = LinkedHashSet<S>()
|
||||
s.buckets[Unindexed]?.let { result.addAll(it) }
|
||||
s.buckets[IdKey(event.id)]?.let { result.addAll(it) }
|
||||
s.buckets[AuthorKey(event.pubKey)]?.let { result.addAll(it) }
|
||||
s.buckets[KindKey(event.kind)]?.let { result.addAll(it) }
|
||||
for (tag in event.tags) {
|
||||
if (tag.size >= 2 && tag[0].length == 1) {
|
||||
s.buckets[TagKey(tag[0], tag[1])]?.let { result.addAll(it) }
|
||||
if (s.unindexed.isNotEmpty()) result.addAll(s.unindexed)
|
||||
s.ids[event.id]?.let { result.addAll(it) }
|
||||
s.authors[event.pubKey]?.let { result.addAll(it) }
|
||||
s.kinds[event.kind]?.let { result.addAll(it) }
|
||||
if (s.tags.isNotEmpty()) {
|
||||
for (tag in event.tags) {
|
||||
if (tag.size >= 2 && tag[0].length == 1) {
|
||||
s.tags[tag[0]]?.get(tag[1])?.let { result.addAll(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -235,19 +261,75 @@ class FilterIndex<S : Any> {
|
||||
keys: List<BucketKey>,
|
||||
) {
|
||||
if (keys.isEmpty()) return
|
||||
val keySet = keys.toSet()
|
||||
val keySet = keys.toPersistentHashSet()
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
val newBuckets = current.buckets.toMutableMap()
|
||||
var ids = current.ids
|
||||
var authors = current.authors
|
||||
var tags = current.tags
|
||||
var kinds = current.kinds
|
||||
var unindexed = current.unindexed
|
||||
for (key in keySet) {
|
||||
val cur = newBuckets[key] ?: emptySet()
|
||||
if (subscriber in cur) continue
|
||||
newBuckets[key] = cur + subscriber
|
||||
when (key) {
|
||||
is IdKey -> ids = ids.addSub(key.id, subscriber)
|
||||
is AuthorKey -> authors = authors.addSub(key.author, subscriber)
|
||||
is KindKey -> kinds = kinds.addSub(key.kind, subscriber)
|
||||
is TagKey -> tags = tags.addTagSub(key.letter, key.value, subscriber)
|
||||
Unindexed -> unindexed = unindexed.add(subscriber)
|
||||
}
|
||||
}
|
||||
val existing = current.assignments[subscriber]
|
||||
val merged = if (existing == null) keySet else existing + keySet
|
||||
val newAssignments = current.assignments + (subscriber to merged)
|
||||
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return
|
||||
val merged = existing?.addAll(keySet) ?: keySet
|
||||
val next = State(ids, authors, tags, kinds, unindexed, current.assignments.put(subscriber, merged))
|
||||
if (state.compareAndSet(current, next)) return
|
||||
}
|
||||
}
|
||||
|
||||
// Per-dimension add/remove of one subscriber, returning the same map
|
||||
// instance when nothing changed so the CAS builds minimal new nodes.
|
||||
private fun <K> PersistentMap<K, PersistentSet<S>>.addSub(
|
||||
key: K,
|
||||
sub: S,
|
||||
): PersistentMap<K, PersistentSet<S>> {
|
||||
val cur = this[key] ?: persistentHashSetOf()
|
||||
val next = cur.add(sub)
|
||||
return if (next === cur) this else put(key, next)
|
||||
}
|
||||
|
||||
private fun <K> PersistentMap<K, PersistentSet<S>>.removeSub(
|
||||
key: K,
|
||||
sub: S,
|
||||
): PersistentMap<K, PersistentSet<S>> {
|
||||
val cur = this[key] ?: return this
|
||||
val next = cur.remove(sub)
|
||||
return when {
|
||||
next === cur -> this
|
||||
next.isEmpty() -> remove(key)
|
||||
else -> put(key, next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PersistentMap<String, PersistentMap<String, PersistentSet<S>>>.addTagSub(
|
||||
letter: String,
|
||||
value: String,
|
||||
sub: S,
|
||||
): PersistentMap<String, PersistentMap<String, PersistentSet<S>>> {
|
||||
val inner = this[letter] ?: persistentHashMapOf()
|
||||
val newInner = inner.addSub(value, sub)
|
||||
return if (newInner === inner) this else put(letter, newInner)
|
||||
}
|
||||
|
||||
private fun PersistentMap<String, PersistentMap<String, PersistentSet<S>>>.removeTagSub(
|
||||
letter: String,
|
||||
value: String,
|
||||
sub: S,
|
||||
): PersistentMap<String, PersistentMap<String, PersistentSet<S>>> {
|
||||
val inner = this[letter] ?: return this
|
||||
val newInner = inner.removeSub(value, sub)
|
||||
return when {
|
||||
newInner === inner -> this
|
||||
newInner.isEmpty() -> remove(letter)
|
||||
else -> put(letter, newInner)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -291,8 +292,16 @@ class RelaySession(
|
||||
// Policy may rewrite filters to match the user's access level.
|
||||
val filters = (result as PolicyResult.Accepted).cmd.filters
|
||||
|
||||
// UNDISPATCHED: the stored replay runs inline on this coroutine —
|
||||
// the reader-pool acquire doesn't suspend when a connection is
|
||||
// free, so EVENT frames and EOSE go out without a scheduler hop
|
||||
// (SmallReqFloorBenchmark: the hop was most of the dispatch
|
||||
// slice on small REQs). The coroutine first parks at the live
|
||||
// tail (awaitCancellation), which is when launch returns and the
|
||||
// job lands in [subscriptions]; commands on this connection are
|
||||
// processed sequentially, so nothing can target the sub earlier.
|
||||
val job =
|
||||
scope.launch {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
try {
|
||||
if (policy.filtersOutgoingEvents) {
|
||||
// Screened path: every event is materialized so the
|
||||
@@ -331,7 +340,20 @@ class RelaySession(
|
||||
},
|
||||
)
|
||||
},
|
||||
onEachLive = { event -> send(EventMessage(cmd.subId, event)) },
|
||||
// Live events arrive with their wire body already
|
||||
// serialized (once per event, shared across every
|
||||
// matching subscription): splice it into the same
|
||||
// per-sub frame prefix as the stored replay, no
|
||||
// per-event EventMessage or re-serialize.
|
||||
onEachLive = { _, body ->
|
||||
sendRaw(
|
||||
buildString(framePrefix.length + body.length + 1) {
|
||||
append(framePrefix)
|
||||
append(body)
|
||||
append(']')
|
||||
},
|
||||
)
|
||||
},
|
||||
onEose = { send(EoseMessage(cmd.subId)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,13 +74,57 @@ class LiveEventStore(
|
||||
* One live REQ subscription. Carries the filters (for the
|
||||
* post-index `match` re-check needed for negative constraints
|
||||
* like `since` / `until` / `tagsAll`) and the delivery callback
|
||||
* the index dispatches into. Identity-keyed inside [FilterIndex].
|
||||
* the index dispatches into. [deliver] receives the event and its
|
||||
* pre-serialized wire body (memoized once per fanout across all
|
||||
* matching subscribers). Identity-keyed inside [FilterIndex].
|
||||
*/
|
||||
private class LiveSubscription(
|
||||
val filters: List<Filter>,
|
||||
val deliver: (Event) -> Unit,
|
||||
val deliver: (Event, String) -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Replay-dedupe set for one REQ. During the historical replay the
|
||||
* store's ids are [record]ed here so the concurrent live path can
|
||||
* drop an event the replay also emitted; after EOSE the set is
|
||||
* [release]d and the live path forwards everything.
|
||||
*
|
||||
* Written from the replay coroutine and read from the [IngestQueue]
|
||||
* drain coroutine (via `fanout`), so every access takes a tiny spin
|
||||
* lock — [locked] is `inline`, so the per-row `record` / `isDuplicate`
|
||||
* calls allocate no closure. The backing `HashSet` is created empty
|
||||
* up front (so the register-before-replay race guarantee holds) but
|
||||
* the JVM defers its table allocation to the first `add`, so a
|
||||
* zero-row replay costs only the empty set object, not a sized table.
|
||||
* It MUST stay a mutable set under a lock, never a copy-on-add
|
||||
* immutable set — `set + id` per row made large replays O(n²).
|
||||
*/
|
||||
private class SeenIds {
|
||||
private val lock = AtomicBoolean(false)
|
||||
private var ids: HashSet<String>? = HashSet()
|
||||
|
||||
private inline fun <R> locked(block: () -> R): R {
|
||||
while (lock.exchange(true)) {
|
||||
while (lock.load()) { }
|
||||
}
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
lock.store(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun record(id: String) {
|
||||
locked { ids?.add(id) }
|
||||
}
|
||||
|
||||
fun isDuplicate(id: String): Boolean = locked { ids?.contains(id) ?: false }
|
||||
|
||||
fun release() {
|
||||
locked { ids = null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget enqueue: hand [event] to the [IngestQueue] and
|
||||
* fire [onComplete] once the writer's batch has a per-row
|
||||
@@ -149,9 +193,17 @@ class LiveEventStore(
|
||||
* batch writer.
|
||||
*/
|
||||
private fun fanout(event: Event) {
|
||||
for (sub in index.candidatesFor(event)) {
|
||||
val candidates = index.candidatesFor(event)
|
||||
if (candidates.isEmpty()) return
|
||||
// Serialize the wire body at most once for this event, no matter
|
||||
// how many subscriptions match it — the old path re-serialized the
|
||||
// whole event per matching subscriber, so a note landing in N live
|
||||
// feeds paid N identical Jackson passes. Lazy so a fanout that
|
||||
// matches nothing (index over-approximates) serializes nothing.
|
||||
var body: String? = null
|
||||
for (sub in candidates) {
|
||||
if (sub.filters.any { it.match(event) }) {
|
||||
sub.deliver(event)
|
||||
sub.deliver(event, body ?: event.toJson().also { body = it })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,61 +230,33 @@ class LiveEventStore(
|
||||
onEose: () -> Unit,
|
||||
) {
|
||||
drainFtsIfSearching(filters)
|
||||
// During the historical replay, record ids the store has
|
||||
// emitted so the live path can dedupe. The index registers
|
||||
// *before* the replay starts (otherwise an event accepted
|
||||
// mid-replay would slip past the live path entirely — same
|
||||
// race the previous SharedFlow-based implementation closed
|
||||
// with `onSubscription`).
|
||||
//
|
||||
// The set is read from the [IngestQueue] drain coroutine (in
|
||||
// `deliver`, called synchronously from `fanout`) and written
|
||||
// from this coroutine (the historical-replay closure below),
|
||||
// so access is guarded by a tiny spin lock (contains/add,
|
||||
// never I/O). It MUST be a mutable set under a lock, not an
|
||||
// immutable Set under an AtomicReference with copy-on-add:
|
||||
// `set + id` copies the whole set per streamed event, which
|
||||
// made large replays accidentally O(n²) — a 100k-event REQ
|
||||
// crawled at ~700 events/s and the rate degraded as the
|
||||
// response grew (see the plan doc's giant-REQ finding).
|
||||
//
|
||||
// Once cleared to null after EOSE, `deliver` short-circuits
|
||||
// and every live event is forwarded.
|
||||
val seenLock = AtomicBoolean(false)
|
||||
var seenIds: HashSet<String>? = HashSet(1024)
|
||||
|
||||
fun <R> seenLocked(block: () -> R): R {
|
||||
while (seenLock.exchange(true)) {
|
||||
while (seenLock.load()) { }
|
||||
}
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
seenLock.store(false)
|
||||
}
|
||||
}
|
||||
// The index registers *before* the replay starts (otherwise an
|
||||
// event accepted mid-replay would slip past the live path entirely
|
||||
// — same race the previous SharedFlow-based implementation closed
|
||||
// with `onSubscription`), and [SeenIds] bridges the two coroutines:
|
||||
// the replay records ids here, the live `deliver` drops duplicates,
|
||||
// and after EOSE the set is released so every live event forwards.
|
||||
val seen = SeenIds()
|
||||
|
||||
val sub =
|
||||
LiveSubscription(
|
||||
filters = filters,
|
||||
deliver = { event ->
|
||||
val duplicate = seenLocked { seenIds?.contains(event.id) ?: false }
|
||||
if (duplicate) return@LiveSubscription
|
||||
onEach(event)
|
||||
deliver = { event, _ ->
|
||||
if (!seen.isDuplicate(event.id)) onEach(event)
|
||||
},
|
||||
)
|
||||
|
||||
index.register(filters, sub)
|
||||
try {
|
||||
store.query<Event>(filters.strippingSearchExtensions()) { event ->
|
||||
seenLocked { seenIds?.add(event.id) }
|
||||
seen.record(event.id)
|
||||
onEach(event)
|
||||
}
|
||||
onEose()
|
||||
// Drop the dedupe set so the live path stops paying for
|
||||
// it. From this point the index drives delivery and
|
||||
// duplicates are no longer possible.
|
||||
seenLocked { seenIds = null }
|
||||
seen.release()
|
||||
// Suspend until the caller's coroutine is cancelled
|
||||
// (e.g. NIP-01 CLOSE or connection drop). The `finally`
|
||||
// unregisters from the index.
|
||||
@@ -255,42 +279,28 @@ class LiveEventStore(
|
||||
ctx: RequestContext,
|
||||
filters: List<Filter>,
|
||||
onEachStored: (RawEvent) -> Unit,
|
||||
onEachLive: (Event) -> Unit,
|
||||
onEachLive: (Event, String) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
) {
|
||||
drainFtsIfSearching(filters)
|
||||
val seenLock = AtomicBoolean(false)
|
||||
var seenIds: HashSet<String>? = HashSet(1024)
|
||||
|
||||
fun <R> seenLocked(block: () -> R): R {
|
||||
while (seenLock.exchange(true)) {
|
||||
while (seenLock.load()) { }
|
||||
}
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
seenLock.store(false)
|
||||
}
|
||||
}
|
||||
val seen = SeenIds()
|
||||
|
||||
val sub =
|
||||
LiveSubscription(
|
||||
filters = filters,
|
||||
deliver = { event ->
|
||||
val duplicate = seenLocked { seenIds?.contains(event.id) ?: false }
|
||||
if (duplicate) return@LiveSubscription
|
||||
onEachLive(event)
|
||||
deliver = { event, body ->
|
||||
if (!seen.isDuplicate(event.id)) onEachLive(event, body)
|
||||
},
|
||||
)
|
||||
|
||||
index.register(filters, sub)
|
||||
try {
|
||||
store.rawQuery(filters.strippingSearchExtensions()) { raw ->
|
||||
seenLocked { seenIds?.add(raw.id) }
|
||||
seen.record(raw.id)
|
||||
onEachStored(raw)
|
||||
}
|
||||
onEose()
|
||||
seenLocked { seenIds = null }
|
||||
seen.release()
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
index.unregister(sub)
|
||||
|
||||
@@ -78,9 +78,9 @@ interface SessionBackend {
|
||||
ctx: RequestContext,
|
||||
filters: List<Filter>,
|
||||
onEachStored: (RawEvent) -> Unit,
|
||||
onEachLive: (Event) -> Unit,
|
||||
onEachLive: (Event, String) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
): Unit = query(ctx, filters, onEachLive, onEose)
|
||||
): Unit = query(ctx, filters, { onEachLive(it, it.toJson()) }, onEose)
|
||||
|
||||
/** Answers a NIP-45 COUNT with an exact cardinality for the caller in [ctx]. */
|
||||
suspend fun count(
|
||||
|
||||
@@ -147,13 +147,38 @@ class EventIndexesModule(
|
||||
*/
|
||||
fun migrateV2AddPubkeyIndex(db: SQLiteConnection) {
|
||||
if (!indexStrategy.indexEventsByPubkeyAlone) return
|
||||
val orderBy =
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
"created_at DESC, id ASC"
|
||||
} else {
|
||||
"created_at DESC"
|
||||
}
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, $orderBy)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, ${orderByColumns()})")
|
||||
}
|
||||
|
||||
private fun orderByColumns() =
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
"created_at DESC, id ASC"
|
||||
} else {
|
||||
"created_at DESC"
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes any flag-gated index the current [indexStrategy] wants
|
||||
* but the on-disk schema predates. Flags are runtime configuration, not
|
||||
* schema — a deployment can flip one without a `user_version` bump — so
|
||||
* this runs idempotently on every open. The first open after enabling a
|
||||
* flag pays a one-time index build over the existing rows; subsequent
|
||||
* opens are no-ops. A disabled flag never drops an existing index (that
|
||||
* stays an operator decision).
|
||||
*/
|
||||
fun ensureOptionalIndexes(db: SQLiteConnection) {
|
||||
if (indexStrategy.indexEventsByCreatedAtAlone) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_created_at_id ON event_headers (${orderByColumns()})")
|
||||
}
|
||||
if (indexStrategy.indexEventsByPubkeyAlone) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, ${orderByColumns()})")
|
||||
}
|
||||
if (indexStrategy.indexTagsByCreatedAtAlone) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_tags_hash ON event_tags (tag_hash, created_at DESC)")
|
||||
}
|
||||
if (indexStrategy.indexTagsWithKindAndPubkey) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)")
|
||||
}
|
||||
}
|
||||
|
||||
val sqlInsertHeader =
|
||||
|
||||
@@ -74,9 +74,21 @@ interface IndexingStrategy {
|
||||
* Activate this if you see too many Tag-centric Filters without
|
||||
* kind AND pubkey at the same time.
|
||||
*
|
||||
* This is a rarely used index (reports by your follows or
|
||||
* NIP-04 DMs for instance) that becomes quite large without
|
||||
* major gains.
|
||||
* This shape (reports by your follows, NIP-04 DM rooms, follows-scoped
|
||||
* community feeds) is not rare on the client side: the 2026-07 filter
|
||||
* assembler survey counted 65 call sites building
|
||||
* `kinds + authors + tags`. Without this index the plan seeks
|
||||
* `(tag_hash, kind)` and reads every row for that tag/kind before
|
||||
* filtering the author.
|
||||
*
|
||||
* Measured by `TagAuthorIndexBenchmark` (jvmTest prodbench): the
|
||||
* DM-room query drops 9.4 ms → 0.6 ms (~15×) at 200k events and
|
||||
* 14.2 ms → 0.66 ms (~21×) at 1M — the gap grows with corpus size —
|
||||
* while batch-insert cost stays inside run noise (49.0 vs 47.4
|
||||
* µs/event at 1M). geode enables it; the client default stays off
|
||||
* because a client store's per-tag row counts are bounded by one
|
||||
* user's data. Flipping it on an existing DB is safe: the index is
|
||||
* built on next open by `EventIndexesModule.ensureOptionalIndexes`.
|
||||
*
|
||||
* Keep in mind that activating too many indexes increases the size of the
|
||||
* DB so much that the indexes themselves won't fit in memory, requiring
|
||||
|
||||
@@ -52,6 +52,17 @@ import androidx.sqlite.SQLiteStatement
|
||||
* exactly at a same-second boundary.
|
||||
*/
|
||||
internal object MergeQueryExecutor {
|
||||
// TODO: the same collect-all + TEMP-B-TREE-sort pattern exists one index
|
||||
// over, on the tag path: `kinds + tags(#e IN [hundreds]) + limit` (the
|
||||
// reactions/replies watcher archetype) unions per-value streams that are
|
||||
// each sorted off `(tag_hash, kind, created_at)` and sorts the union.
|
||||
// `streamCount` currently rejects any filter with tags, so those queries
|
||||
// never merge. Measured by `TagAuthorIndexBenchmark`: `#e IN 300,
|
||||
// limit 500` costs 12.8 ms cold at 200k events and 14.2 ms at 1M
|
||||
// (6.7 ms with indexTagsWithKindAndPubkey on) — tolerable, but it
|
||||
// scales with matching history like the follow-feed shape did; extend
|
||||
// the merge to per-tag-value streams if the relayBench
|
||||
// `reactions-watch` scenario shows it in the profile vs strfry.
|
||||
const val COLS = "id, pubkey, created_at, kind, tags, content, sig"
|
||||
|
||||
/**
|
||||
|
||||
@@ -160,6 +160,11 @@ class SQLiteEventStore(
|
||||
setUserVersion(this, DATABASE_VERSION)
|
||||
}
|
||||
}
|
||||
// Flag-gated indexes are runtime config, not schema: a
|
||||
// deployment that flips an IndexingStrategy flag on an
|
||||
// existing DB gets the index built here (idempotent,
|
||||
// one-time cost), with no user_version bump involved.
|
||||
eventIndexModule.ensureOptionalIndexes(db)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,8 +202,20 @@ fun Filter.strippingSearchExtensions(): Filter {
|
||||
/**
|
||||
* Applies [strippingSearchExtensions] to every filter, returning this
|
||||
* same list when no filter carried extension tokens.
|
||||
*
|
||||
* This runs on every REQ/COUNT/snapshot, and the overwhelming majority
|
||||
* carry no `search` term at all, so the no-search case must not allocate:
|
||||
* bail before building any list when nothing could be stripped.
|
||||
*/
|
||||
fun List<Filter>.strippingSearchExtensions(): List<Filter> {
|
||||
var hasSearch = false
|
||||
for (i in indices) {
|
||||
if (!this[i].search.isNullOrEmpty()) {
|
||||
hasSearch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!hasSearch) return this
|
||||
var changed = false
|
||||
val out =
|
||||
map {
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher
|
||||
import java.nio.file.DirectoryStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -36,16 +37,23 @@ import kotlin.io.path.exists
|
||||
*
|
||||
* Step-2 coverage:
|
||||
* - `ids` → direct canonical opens
|
||||
* - `tagsAll`/`tags` → tag index union (first key)
|
||||
* - `kinds` → kind index union
|
||||
* - `authors` → author index union
|
||||
* - `tagsAll`/`tags`/`kinds`/`authors` → cheapest index tree drives
|
||||
* (capped entry-count comparison), the rest post-filter
|
||||
* - otherwise → full scan via every `idx/kind/<k>/` subtree
|
||||
*
|
||||
* The planner is intentionally dumb about selectivity — "first available
|
||||
* driver wins". A cost-based picker (smallest listing) can slot in
|
||||
* later without changing callers. All FilterMatcher semantics (tag
|
||||
* AND/OR, since/until, id, author, kind cross-checks) are enforced in
|
||||
* the orchestrator, so picking a loose driver is correctness-safe.
|
||||
* Driver choice is cost-based: every legal driver (each `tagsAll` value
|
||||
* alone — AND semantics make any single value a complete driver — each
|
||||
* `tags` key's value union, the kind set, the author set) opens a lazy
|
||||
* directory iterator, all are drained in lockstep, and the first to
|
||||
* exhaust — the smallest listing — drives. A giant tree (`idx/kind/1/`
|
||||
* with a million entries) is therefore never read past ~the smallest
|
||||
* candidate's size. Before the pick, the fixed tags → kinds → authors
|
||||
* order sent `authors + kinds + limit` — the most common CLI shape —
|
||||
* through the kind tree: 149 ms fixed-order vs 4.0 ms cost-based at 30k
|
||||
* events per `FsDriverSelectionBenchmark` (floor: author-only at
|
||||
* 1.4 ms). All FilterMatcher semantics (tag AND/OR,
|
||||
* since/until, id, author, kind cross-checks) are enforced in the
|
||||
* orchestrator, so any driver pick is correctness-safe.
|
||||
*/
|
||||
internal class FsQueryPlanner(
|
||||
private val layout: FsLayout,
|
||||
@@ -74,19 +82,100 @@ internal class FsQueryPlanner(
|
||||
return ftsDriver(search)
|
||||
}
|
||||
|
||||
firstTagKey(filter)?.let { (name, values) ->
|
||||
return mergeDesc(values.map { v -> walkDir(layout.tagValueDir(name, v, hasher.hash(name, v))) })
|
||||
val candidates = driverCandidates(filter)
|
||||
if (candidates.isEmpty()) return allKindsDriver()
|
||||
|
||||
return mergeDesc(cheapestDriver(candidates).map { walkDir(it) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Every set of index directories that, walked and post-filtered, yields
|
||||
* a superset of the filter's matches:
|
||||
* - each `tagsAll` value alone (AND semantics — every match carries it),
|
||||
* - each `tags` key's full value union (OR within a key, AND across),
|
||||
* - the kind set, and the author set.
|
||||
* Listed in the old fixed-priority order so [cheapestDriver] keeps that
|
||||
* order on cost ties.
|
||||
*/
|
||||
private fun driverCandidates(filter: Filter): List<List<Path>> {
|
||||
val out = ArrayList<List<Path>>()
|
||||
filter.tagsAll?.forEach { (name, values) ->
|
||||
values.forEach { v -> out.add(listOf(layout.tagValueDir(name, v, hasher.hash(name, v)))) }
|
||||
}
|
||||
filter.tags?.forEach { (name, values) ->
|
||||
if (values.isNotEmpty()) {
|
||||
out.add(values.map { v -> layout.tagValueDir(name, v, hasher.hash(name, v)) })
|
||||
}
|
||||
}
|
||||
filter.kinds?.takeIf { it.isNotEmpty() }?.let { kinds ->
|
||||
out.add(kinds.map { layout.kindDir(it) })
|
||||
}
|
||||
filter.authors?.takeIf { it.isNotEmpty() }?.let { authors ->
|
||||
out.add(authors.map { layout.authorDir(it) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest candidate by lockstep listing drain: one lazy directory
|
||||
* iterator per candidate, all advanced [COST_BATCH] entries per round —
|
||||
* the first to exhaust its listing is the smallest, so a giant tree is
|
||||
* never read past ~the smallest candidate's size (a candidate that
|
||||
* exhausts on round one costs the others one batch each). A candidate
|
||||
* whose dirs are all missing exhausts immediately: driving from an empty
|
||||
* mandatory predicate correctly yields an empty result. If every
|
||||
* candidate survives [COST_CAP] entries, all are huge and relative
|
||||
* driver choice stops mattering — the first (old fixed-priority order)
|
||||
* wins.
|
||||
*/
|
||||
private fun cheapestDriver(candidates: List<List<Path>>): List<Path> {
|
||||
if (candidates.size == 1) return candidates[0]
|
||||
val cursors = candidates.map { EntryCursor(it) }
|
||||
try {
|
||||
var advanced = 0L
|
||||
while (advanced < COST_CAP) {
|
||||
for (i in cursors.indices) {
|
||||
if (!cursors[i].skip(COST_BATCH)) return candidates[i]
|
||||
}
|
||||
advanced += COST_BATCH
|
||||
}
|
||||
return candidates[0]
|
||||
} finally {
|
||||
cursors.forEach { it.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy entry iterator over a candidate's directories, in order. */
|
||||
private class EntryCursor(
|
||||
dirs: List<Path>,
|
||||
) : AutoCloseable {
|
||||
private val remaining = ArrayDeque(dirs)
|
||||
private var stream: DirectoryStream<Path>? = null
|
||||
private var iter: Iterator<Path> = emptyList<Path>().iterator()
|
||||
|
||||
/** Advances up to [n] entries; false when the listing ends first. */
|
||||
fun skip(n: Int): Boolean {
|
||||
var left = n
|
||||
while (left > 0) {
|
||||
if (iter.hasNext()) {
|
||||
iter.next()
|
||||
left--
|
||||
continue
|
||||
}
|
||||
close()
|
||||
val dir = remaining.removeFirstOrNull() ?: return false
|
||||
if (!Files.isDirectory(dir)) continue
|
||||
stream = Files.newDirectoryStream(dir)
|
||||
iter = stream!!.iterator()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
filter.kinds?.let { kinds ->
|
||||
return mergeDesc(kinds.map { walkDir(layout.kindDir(it)) })
|
||||
override fun close() {
|
||||
stream?.close()
|
||||
stream = null
|
||||
iter = emptyList<Path>().iterator()
|
||||
}
|
||||
|
||||
filter.authors?.let { authors ->
|
||||
return mergeDesc(authors.map { walkDir(layout.authorDir(it)) })
|
||||
}
|
||||
|
||||
return allKindsDriver()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,14 +390,15 @@ internal class FsQueryPlanner(
|
||||
var top: Candidate,
|
||||
)
|
||||
|
||||
// ---- helpers ------------------------------------------------------
|
||||
private companion object {
|
||||
/** Entries each candidate's cursor advances per lockstep round. */
|
||||
const val COST_BATCH = 64
|
||||
|
||||
/** First tag filter with at least one value, preferring `tagsAll`. */
|
||||
private fun firstTagKey(filter: Filter): Pair<String, List<String>>? {
|
||||
filter.tagsAll?.firstNonEmpty()?.let { return it }
|
||||
filter.tags?.firstNonEmpty()?.let { return it }
|
||||
return null
|
||||
/**
|
||||
* Stop draining once every candidate has survived this many
|
||||
* entries: past it they are all huge, relative choice stops
|
||||
* mattering, and the first candidate in priority order wins.
|
||||
*/
|
||||
const val COST_CAP = 65_536L
|
||||
}
|
||||
|
||||
private fun Map<String, List<String>>.firstNonEmpty(): Pair<String, List<String>>? = entries.firstOrNull { it.value.isNotEmpty() }?.let { it.key to it.value }
|
||||
}
|
||||
|
||||
@@ -32,11 +32,14 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@@ -65,6 +68,8 @@ class SmallReqFloorBenchmark {
|
||||
const val AUTHORS = 2_500 // ~20 events per author, matching author-archive
|
||||
const val ROUNDS = 400
|
||||
const val WARMUP = 100
|
||||
const val IDLE_SUBS = 1_000
|
||||
const val FANOUT_SUBS = 200
|
||||
}
|
||||
|
||||
private fun hexId(seed: Int): String = seed.toString(16).padStart(64, '0')
|
||||
@@ -122,16 +127,19 @@ class SmallReqFloorBenchmark {
|
||||
}
|
||||
|
||||
// --- B: backend queryRaw to EOSE (live machinery included) ---
|
||||
// UNDISPATCHED mirrors the production path (RelaySession.handleReq
|
||||
// starts the query coroutine undispatched), so B−A is the live
|
||||
// machinery itself, not a benchmark-only scheduler hop.
|
||||
suspend fun timeBackend(round: Int): Long {
|
||||
val eose = CompletableDeferred<Long>()
|
||||
val t0 = System.nanoTime()
|
||||
val job =
|
||||
scope.launch {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
live.queryRaw(
|
||||
ctx = ctx,
|
||||
filters = listOf(filterFor(round)),
|
||||
onEachStored = {},
|
||||
onEachLive = {},
|
||||
onEachLive = { _, _ -> },
|
||||
onEose = { eose.complete(System.nanoTime() - t0) },
|
||||
)
|
||||
}
|
||||
@@ -143,6 +151,33 @@ class SmallReqFloorBenchmark {
|
||||
val b = LongArray(ROUNDS)
|
||||
repeat(ROUNDS) { b[it] = timeBackend(it) }
|
||||
|
||||
// --- B@1k: same, with 1000 idle live subscriptions parked ---
|
||||
// Register/unregister cost scales with the live population
|
||||
// (FilterIndex mutates a shared snapshot per REQ open/close),
|
||||
// which the single-sub stage can't see. Each idle sub filters
|
||||
// on an author absent from the corpus: 0-row replay, then parks
|
||||
// at the live tail and stays registered.
|
||||
val idleJobs =
|
||||
(0 until IDLE_SUBS).map { i ->
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
val job =
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
live.queryRaw(
|
||||
ctx = ctx,
|
||||
filters = listOf(Filter(authors = listOf(hexId(1_000_000 + i)), kinds = listOf(1), limit = 1)),
|
||||
onEachStored = {},
|
||||
onEachLive = { _, _ -> },
|
||||
onEose = { ready.complete(Unit) },
|
||||
)
|
||||
}
|
||||
ready.await()
|
||||
job
|
||||
}
|
||||
repeat(WARMUP) { timeBackend(it) }
|
||||
val b1k = LongArray(ROUNDS)
|
||||
repeat(ROUNDS) { b1k[it] = timeBackend(it) }
|
||||
idleJobs.forEach { it.cancel() }
|
||||
|
||||
// --- C: full session dispatch, REQ json in → EOSE frame out ---
|
||||
suspend fun timeSession(round: Int): Long {
|
||||
val eose = CompletableDeferred<Long>()
|
||||
@@ -160,15 +195,67 @@ class SmallReqFloorBenchmark {
|
||||
val c = LongArray(ROUNDS)
|
||||
repeat(ROUNDS) { c[it] = timeSession(it) }
|
||||
|
||||
// --- fanout: one live event → FANOUT_SUBS live subscriptions ---
|
||||
// All subs register on `live` directly (via queryRaw, same backend
|
||||
// we submit into) and filter an author with no stored events (0-row
|
||||
// replay, then park live). Submitting one matching event fans out to
|
||||
// every sub; the body is serialized once and spliced per sub, so
|
||||
// this measures the shared-serialization path (#2). skipVerify so
|
||||
// the synthetic sig is accepted. Fewer rounds than A–C: each round
|
||||
// is FANOUT_SUBS deliveries and a real group-commit insert.
|
||||
val fanAuthor = hexId(9_000_001)
|
||||
val delivered = AtomicInteger(0)
|
||||
var fanDone = CompletableDeferred<Long>()
|
||||
var fanStart = 0L
|
||||
val fanJobs =
|
||||
(0 until FANOUT_SUBS).map {
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
val job =
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
live.queryRaw(
|
||||
ctx = ctx,
|
||||
filters = listOf(Filter(authors = listOf(fanAuthor), kinds = listOf(1))),
|
||||
onEachStored = {},
|
||||
onEachLive = { _, _ ->
|
||||
if (delivered.incrementAndGet() == FANOUT_SUBS) {
|
||||
fanDone.complete(System.nanoTime() - fanStart)
|
||||
}
|
||||
},
|
||||
onEose = { ready.complete(Unit) },
|
||||
)
|
||||
}
|
||||
ready.await()
|
||||
job
|
||||
}
|
||||
val fanRounds = 60
|
||||
val fanWarmup = 15
|
||||
val fan = LongArray(fanRounds)
|
||||
var fanSeq = 0
|
||||
repeat(fanWarmup + fanRounds) { r ->
|
||||
delivered.set(0)
|
||||
fanDone = CompletableDeferred()
|
||||
val ev = EventFactory.create<Event>(hexId(9_500_000 + fanSeq), fanAuthor, 1_700_000_000L + fanSeq, 1, emptyArray(), "fanout $fanSeq", sig)
|
||||
fanSeq++
|
||||
fanStart = System.nanoTime()
|
||||
live.submit(ev, skipVerify = true) {}
|
||||
val nanos = withTimeout(30_000) { fanDone.await() }
|
||||
if (r >= fanWarmup) fan[r - fanWarmup] = nanos
|
||||
}
|
||||
fanJobs.forEach { it.cancel() }
|
||||
|
||||
assertEquals(true, rowsA > 0, "author filters must return rows")
|
||||
|
||||
val mA = median(a)
|
||||
val mB = median(b)
|
||||
val mB1k = median(b1k)
|
||||
val mC = median(c)
|
||||
println("SmallReqFloorBenchmark @ ${EVENTS / 1000}k events, ~${rowsA / ROUNDS} rows/req, medians of $ROUNDS")
|
||||
println(" A raw store query: ${"%6.3f".format(mA)} ms")
|
||||
println(" B backend queryRaw→EOSE: ${"%6.3f".format(mB)} ms (live machinery +${"%6.3f".format(mB - mA)})")
|
||||
println(" B@${IDLE_SUBS} idle subs: ${"%6.3f".format(mB1k)} ms (population cost +${"%6.3f".format(mB1k - mB)})")
|
||||
println(" C session REQ→EOSE: ${"%6.3f".format(mC)} ms (dispatch+frames +${"%6.3f".format(mC - mB)})")
|
||||
val mFan = median(fan)
|
||||
println(" fanout 1→$FANOUT_SUBS live subs: ${"%6.3f".format(mFan)} ms (${"%.2f".format(mFan * 1000 / FANOUT_SUBS)} µs/sub; body serialized once)")
|
||||
|
||||
server.close()
|
||||
scope.cancel()
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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.nip01Core.relay.prodbench
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
|
||||
/**
|
||||
* Measures the two tag-path query shapes the client filter-assembler survey
|
||||
* (2026-07) found hot but that no existing benchmark covers:
|
||||
*
|
||||
* 1. **tag ∩ author (DM-room shape)** — `kinds=[4] AND authors=[peer] AND
|
||||
* #p=[me] LIMIT n`. 65 assembler call sites build this shape (every
|
||||
* NIP-04 chat room, reports-by-follows, follows-scoped community feeds).
|
||||
* [com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy.indexTagsWithKindAndPubkey]
|
||||
* gates a covering `(tag_hash, kind, pubkey_hash, created_at)` index for
|
||||
* it, but the flag is off everywhere (including geode). Without it the
|
||||
* plan seeks `(tag_hash, kind)` and reads EVERY DM the user has ever
|
||||
* received before filtering to the one peer. This compares query latency
|
||||
* with the flag off vs on, and the batch-insert cost the extra index adds.
|
||||
*
|
||||
* 2. **large-IN tag watcher (reactions shape)** — `kinds=[7] AND
|
||||
* #e=[hundreds of note ids] LIMIT n`. The per-value streams come sorted
|
||||
* off `(tag_hash, kind, created_at)`, but their union does not, so SQLite
|
||||
* collects every matching row and TEMP-B-TREE sorts to the limit — the
|
||||
* tag-index analogue of the follow-feed regression
|
||||
* [MergeQueryExecutor] fixed for author streams. Reported with and
|
||||
* without a `since` bound to show what EOSE-warm steady state hides.
|
||||
*
|
||||
* Size the seed with `-DtagBenchScale=N` (default 1 ≈ ~200k events).
|
||||
*/
|
||||
class TagAuthorIndexBenchmark {
|
||||
companion object {
|
||||
val SCALE = System.getProperty("tagBenchScale")?.toInt() ?: 1
|
||||
}
|
||||
|
||||
private val hex = "0123456789abcdef"
|
||||
|
||||
private fun mix(seed: Long): Long {
|
||||
var z = seed + -0x61c8864680b583ebL
|
||||
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
|
||||
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
|
||||
private fun hex64(
|
||||
salt: Long,
|
||||
index: Int,
|
||||
): String {
|
||||
val out = CharArray(64)
|
||||
for (w in 0 until 4) {
|
||||
val v = mix(salt * 1_000_003 + index.toLong() * 4 + w)
|
||||
for (b in 0 until 8) {
|
||||
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
|
||||
out[(w * 8 + b) * 2] = hex[byte ushr 4]
|
||||
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
|
||||
}
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
|
||||
private val sig = "0".repeat(128)
|
||||
private var idSeq = 0
|
||||
|
||||
private fun ev(
|
||||
pubkey: String,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
): Event = EventFactory.create(hex64(7, idSeq++), pubkey, createdAt, kind, tags, "", sig)
|
||||
|
||||
private fun seedEvents(): List<Event> {
|
||||
idSeq = 0
|
||||
val base = 1_700_000_000L
|
||||
val span = 3_000_000L // ~35 days
|
||||
val me = hex64(9, 0)
|
||||
val events = ArrayList<Event>(220_000 * SCALE)
|
||||
|
||||
// DM inbox: 200 peers, 300 DMs each → 60k kind-4 rows sharing the
|
||||
// same (p:me) tag hash. The room query wants one peer's 300.
|
||||
val peers = (0 until 200).map { hex64(2, it) }
|
||||
for ((i, peer) in peers.withIndex()) {
|
||||
repeat(300 * SCALE) {
|
||||
val ts = base + (mix(i * 131L + it) and 0x7fffffff) % span
|
||||
events.add(ev(peer, ts, 4, arrayOf(arrayOf("p", me))))
|
||||
}
|
||||
}
|
||||
|
||||
// Notification noise: 2000 authors mention me in kind-1 notes, so
|
||||
// (p:me) spans multiple kinds like a real inbox does.
|
||||
repeat(40_000 * SCALE) {
|
||||
val author = hex64(3, it % 2_000)
|
||||
val ts = base + (mix(it * 17L) and 0x7fffffff) % span
|
||||
events.add(ev(author, ts, 1, arrayOf(arrayOf("p", me))))
|
||||
}
|
||||
|
||||
// Reactions: 100k kind-7 events spread over 5000 target notes, for
|
||||
// the large-IN watcher shape.
|
||||
val noteIds = (0 until 5_000).map { hex64(5, it) }
|
||||
repeat(100_000 * SCALE) {
|
||||
val author = hex64(4, it % 3_000)
|
||||
val ts = base + (mix(it * 29L) and 0x7fffffff) % span
|
||||
events.add(ev(author, ts, 7, arrayOf(arrayOf("e", noteIds[it % noteIds.size]))))
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compareTagAuthorIndex() =
|
||||
runBlocking {
|
||||
val events = seedEvents()
|
||||
val me = hex64(9, 0)
|
||||
val peers = (0 until 200).map { hex64(2, it) }
|
||||
val noteIds = (0 until 5_000).map { hex64(5, it) }
|
||||
|
||||
println("─ TagAuthorIndexBenchmark: ${events.size} events (scale=$SCALE) ─")
|
||||
|
||||
val strategies =
|
||||
listOf(
|
||||
"flag-off" to DefaultIndexingStrategy(indexFullTextSearch = false),
|
||||
"flag-on " to DefaultIndexingStrategy(indexFullTextSearch = false, indexTagsWithKindAndPubkey = true),
|
||||
)
|
||||
|
||||
for ((label, strategy) in strategies) {
|
||||
val store = EventStore(dbName = null, indexStrategy = strategy)
|
||||
|
||||
val t0 = System.nanoTime()
|
||||
events.chunked(10_000).forEach { store.batchInsert(it) }
|
||||
val insertMs = (System.nanoTime() - t0) / 1e6
|
||||
println(" ═ $label ═ insert: %.0f ms (%.1f µs/event)".format(insertMs, insertMs * 1000 / events.size))
|
||||
|
||||
// 1. DM room: one peer's DMs out of the whole (p:me) inbox.
|
||||
val room = Filter(kinds = listOf(4), authors = listOf(peers[42]), tags = mapOf("p" to listOf(me)), limit = 100)
|
||||
time(store, "dm-room (#p ∩ author ∩ kind, limit 100)", room)
|
||||
|
||||
// 2. Reactions watcher: 300 note ids, cold (no since).
|
||||
val watcher = Filter(kinds = listOf(7), tags = mapOf("e" to noteIds.take(300)), limit = 500)
|
||||
time(store, "reactions (#e IN 300, limit 500, cold)", watcher)
|
||||
|
||||
// 3. Same watcher, EOSE-warm (since bounds the window).
|
||||
val warm = watcher.copy(since = 1_700_000_000L + 2_900_000L)
|
||||
time(store, "reactions (#e IN 300, limit 500, since)", warm)
|
||||
|
||||
store.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun time(
|
||||
store: EventStore,
|
||||
label: String,
|
||||
filter: Filter,
|
||||
) {
|
||||
repeat(3) { store.query<Event>(filter) }
|
||||
val runs = 10
|
||||
var rows = 0
|
||||
val start = System.nanoTime()
|
||||
repeat(runs) { rows = store.query<Event>(filter).size }
|
||||
val ms = (System.nanoTime() - start) / 1e6 / runs
|
||||
println(" %-42s %8.2f ms (%d rows)".format(label, ms, rows))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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.nip01Core.store.fs
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.test.Test
|
||||
|
||||
/**
|
||||
* Guards [FsQueryPlanner]'s cost-based driver pick on the
|
||||
* `authors + kinds + limit` shape — the most common CLI query (27
|
||||
* assembler call sites; every `amy feed`-style author timeline over
|
||||
* non-replaceable kinds).
|
||||
*
|
||||
* Under the pre-pick fixed order (tags → kinds → authors),
|
||||
* `Filter(authors=[pk], kinds=[1], limit=n)` drove from `idx/kind/1/`
|
||||
* (the biggest tree in any real store) and post-filtered the author:
|
||||
* 149 ms at 30k events. The lockstep pick drives from the author tree
|
||||
* and runs at ~4 ms. The benchmark times:
|
||||
*
|
||||
* - **planner (cost-based pick)**: the filter as the planner runs it —
|
||||
* should sit near the floor, far below a kind-tree walk.
|
||||
* - **author-driver emulation**: the author tree walked via an
|
||||
* authors-only query with the kind check applied by the caller — the
|
||||
* reference the pick is expected to match or beat.
|
||||
* - **author-only floor**: `authors + limit` with no kind, the cheapest
|
||||
* possible walk of the same tree.
|
||||
*
|
||||
* Size the seed with `-DfsBenchScale=N` (default 1 ≈ ~30k events; each
|
||||
* event is a file + ~3 hardlinks, so seeding dominates wall time).
|
||||
*/
|
||||
class FsDriverSelectionBenchmark {
|
||||
companion object {
|
||||
val SCALE = System.getProperty("fsBenchScale")?.toInt() ?: 1
|
||||
}
|
||||
|
||||
private val hex = "0123456789abcdef"
|
||||
|
||||
private fun mix(seed: Long): Long {
|
||||
var z = seed + -0x61c8864680b583ebL
|
||||
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
|
||||
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
|
||||
private fun hex64(
|
||||
salt: Long,
|
||||
index: Int,
|
||||
): String {
|
||||
val out = CharArray(64)
|
||||
for (w in 0 until 4) {
|
||||
val v = mix(salt * 1_000_003 + index.toLong() * 4 + w)
|
||||
for (b in 0 until 8) {
|
||||
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
|
||||
out[(w * 8 + b) * 2] = hex[byte ushr 4]
|
||||
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
|
||||
}
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
|
||||
private val sig = "0".repeat(128)
|
||||
private var idSeq = 0
|
||||
|
||||
private fun ev(
|
||||
pubkey: String,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
): Event = EventFactory.create(hex64(7, idSeq++), pubkey, createdAt, kind, emptyArray(), "", sig)
|
||||
|
||||
@Test
|
||||
fun compareDrivers() =
|
||||
runBlocking {
|
||||
val root: Path = Files.createTempDirectory("fs-driver-bench-")
|
||||
val store = FsEventStore(root)
|
||||
try {
|
||||
val base = 1_700_000_000L
|
||||
val span = 3_000_000L
|
||||
val target = hex64(9, 0)
|
||||
|
||||
// Background: 300 authors × 100 kind-1 notes.
|
||||
val bg = ArrayList<Event>(30_000 * SCALE + 300)
|
||||
repeat(30_000 * SCALE) {
|
||||
val author = hex64(1, it % 300)
|
||||
bg.add(ev(author, base + (mix(it * 31L) and 0x7fffffff) % span, 1))
|
||||
}
|
||||
// Target author: 200 kind-1 notes + 50 kind-7 reactions.
|
||||
repeat(200) { bg.add(ev(target, base + (mix(it * 131L) and 0x7fffffff) % span, 1)) }
|
||||
repeat(50) { bg.add(ev(target, base + (mix(it * 61L) and 0x7fffffff) % span, 7)) }
|
||||
|
||||
val t0 = System.nanoTime()
|
||||
store.transaction { bg.forEach { insert(it) } }
|
||||
val insertMs = (System.nanoTime() - t0) / 1e6
|
||||
println("─ FsDriverSelectionBenchmark: ${bg.size} events (scale=$SCALE), seed %.0f ms ─".format(insertMs))
|
||||
|
||||
// The planner's own pick — expected to choose the author
|
||||
// tree over the ~30k-entry kind-1 tree.
|
||||
val kindDriven = Filter(authors = listOf(target), kinds = listOf(1), limit = 50)
|
||||
time(store, "planner (cost-based pick)") { store.query<Event>(kindDriven).size }
|
||||
|
||||
// Reference: author tree walked explicitly, kind checked
|
||||
// by the caller — the pick should match or beat this.
|
||||
time(store, "author-driver emulation") {
|
||||
store
|
||||
.query<Event>(Filter(authors = listOf(target), limit = 250))
|
||||
.asSequence()
|
||||
.filter { it.kind == 1 }
|
||||
.take(50)
|
||||
.count()
|
||||
}
|
||||
|
||||
// Floor: author-only shape, the cheapest walk of the tree.
|
||||
val authorOnly = Filter(authors = listOf(target), limit = 50)
|
||||
time(store, "author-only floor") { store.query<Event>(authorOnly).size }
|
||||
} finally {
|
||||
store.close()
|
||||
if (root.exists()) {
|
||||
Files.walk(root).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun time(
|
||||
store: FsEventStore,
|
||||
label: String,
|
||||
run: () -> Int,
|
||||
) {
|
||||
repeat(3) { run() }
|
||||
val runs = 10
|
||||
var rows = 0
|
||||
val start = System.nanoTime()
|
||||
repeat(runs) { rows = run() }
|
||||
val ms = (System.nanoTime() - start) / 1e6 / runs
|
||||
println(" %-32s %8.2f ms (%d rows)".format(label, ms, rows))
|
||||
}
|
||||
}
|
||||
@@ -70,11 +70,11 @@ object Scenarios {
|
||||
}
|
||||
|
||||
val topAuthors = notesByAuthor.entries.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key }).map { it.key }
|
||||
val hottestThread =
|
||||
val hotNotes =
|
||||
eTagRefs.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
|
||||
.firstOrNull()
|
||||
?.key
|
||||
.map { it.key }
|
||||
val hottestThread = hotNotes.firstOrNull()
|
||||
val mostMentioned =
|
||||
pTagRefs.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
|
||||
@@ -86,6 +86,23 @@ object Scenarios {
|
||||
.firstOrNull()
|
||||
?.key
|
||||
|
||||
// The author that most often tags the most-mentioned pubkey — a
|
||||
// conversation pair for the tag ∩ author (DM-room) query shape.
|
||||
val conversationPeer =
|
||||
mostMentioned?.let { me ->
|
||||
val byAuthor = HashMap<String, Int>()
|
||||
for (e in events) {
|
||||
if (e.kind != 1 || e.pubKey == me) continue
|
||||
if (e.tags.any { it.size >= 2 && it[0] == "p" && it[1] == me }) {
|
||||
byAuthor.merge(e.pubKey, 1, Int::plus)
|
||||
}
|
||||
}
|
||||
byAuthor.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
|
||||
.firstOrNull()
|
||||
?.key
|
||||
}
|
||||
|
||||
// Evenly spread sample of note ids — a "fetch these 100 events" batch.
|
||||
val idSample =
|
||||
if (noteIds.size <= 100) {
|
||||
@@ -159,6 +176,33 @@ object Scenarios {
|
||||
),
|
||||
)
|
||||
}
|
||||
if (mostMentioned != null && conversationPeer != null) {
|
||||
// The tag ∩ author ∩ kind shape (65 client assembler call
|
||||
// sites: NIP-04 DM rooms, reports-by-follows, follows-scoped
|
||||
// community feeds). Modeled on kind 1 because public corpora
|
||||
// carry no DMs; the index path exercised is identical.
|
||||
add(
|
||||
Scenario(
|
||||
"conversation",
|
||||
"notes by one author tagging the most-mentioned pubkey (DM-room shape)",
|
||||
Filter(kinds = listOf(1), authors = listOf(conversationPeer), tags = mapOf("p" to listOf(mostMentioned)), limit = 500),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hotNotes.size > 1) {
|
||||
// Large-IN tag watcher: per-value streams come sorted off the
|
||||
// tag index but their union does not, exposing whether the
|
||||
// store collects+sorts or merges. 150 values stays inside
|
||||
// strfry's default 200-element filter cap.
|
||||
val watched = hotNotes.take(150)
|
||||
add(
|
||||
Scenario(
|
||||
"reactions-watch",
|
||||
"reactions on the ${watched.size} hottest notes (visible-feed reaction watcher)",
|
||||
Filter(kinds = listOf(7), tags = mapOf("e" to watched), limit = 500),
|
||||
),
|
||||
)
|
||||
}
|
||||
topHashtag?.let {
|
||||
add(
|
||||
Scenario(
|
||||
|
||||
Reference in New Issue
Block a user