diff --git a/flake.nix b/flake.nix index 673613da..4b22a143 100644 --- a/flake.nix +++ b/flake.nix @@ -95,10 +95,14 @@ knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; knot = self.callPackage ./nix/pkgs/knot.nix {}; dolly = self.callPackage ./nix/pkgs/dolly.nix {}; + did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {}; + bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {}; + bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {}; + tap = self.callPackage ./nix/pkgs/tap.nix {}; }); in { overlays.default = final: prev: { - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly did-method-plc bluesky-jetstream bluesky-relay tap; }; packages = forAllSystems (system: let @@ -119,6 +123,10 @@ sqlite-lib docs dolly + did-method-plc + bluesky-jetstream + bluesky-relay + tap ; pkgsStatic-appview = staticPackages.appview; @@ -324,5 +332,29 @@ services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; }; + nixosModules.did-method-plc = { + lib, + pkgs, + ... + }: { + imports = [./nix/modules/did-method-plc.nix]; + services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; + }; + nixosModules.bluesky-relay = { + lib, + pkgs, + ... + }: { + imports = [./nix/modules/bluesky-relay.nix]; + services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; + }; + nixosModules.bluesky-jetstream = { + lib, + pkgs, + ... + }: { + imports = [./nix/modules/bluesky-jetstream.nix]; + services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; + }; }; } diff --git a/nix/modules/bluesky-jetstream.nix b/nix/modules/bluesky-jetstream.nix new file mode 100644 index 00000000..589d29a0 --- /dev/null +++ b/nix/modules/bluesky-jetstream.nix @@ -0,0 +1,64 @@ +{ + config, + pkgs, + lib, + ... +}: let + cfg = config.services.bluesky-jetstream; +in + with lib; { + options.services.bluesky-jetstream = { + enable = mkEnableOption "jetstream server"; + package = mkPackageOption pkgs "bluesky-jetstream" {}; + + # dataDir = mkOption { + # type = types.str; + # default = "/var/lib/jetstream"; + # description = "directory to store data (pebbleDB)"; + # }; + livenessTtl = mkOption { + type = types.int; + default = 15; + description = "time to restart when no event detected (seconds)"; + }; + websocketUrl = mkOption { + type = types.str; + default = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos"; + description = "full websocket path to the ATProto SubscribeRepos XRPC endpoint"; + }; + }; + config = mkIf cfg.enable { + systemd.services.bluesky-jetstream = { + description = "bluesky jetstream"; + after = ["network.target" "pds.service"]; + wantedBy = ["multi-user.target"]; + + serviceConfig = { + User = "jetstream"; + Group = "jetstream"; + StateDirectory = "jetstream"; + StateDirectoryMode = "0755"; + # preStart = '' + # mkdir -p "${cfg.dataDir}" + # chown -R jetstream:jetstream "${cfg.dataDir}" + # ''; + # WorkingDirectory = cfg.dataDir; + Environment = [ + "JETSTREAM_DATA_DIR=/var/lib/jetstream/data" + "JETSTREAM_LIVENESS_TTL=${toString cfg.livenessTtl}s" + "JETSTREAM_WS_URL=${cfg.websocketUrl}" + ]; + ExecStart = getExe cfg.package; + Restart = "always"; + RestartSec = 5; + }; + }; + users = { + users.jetstream = { + group = "jetstream"; + isSystemUser = true; + }; + groups.jetstream = {}; + }; + }; + } diff --git a/nix/modules/bluesky-relay.nix b/nix/modules/bluesky-relay.nix new file mode 100644 index 00000000..4d75372f --- /dev/null +++ b/nix/modules/bluesky-relay.nix @@ -0,0 +1,48 @@ +{ + config, + pkgs, + lib, + ... +}: let + cfg = config.services.bluesky-relay; +in + with lib; { + options.services.bluesky-relay = { + enable = mkEnableOption "relay server"; + package = mkPackageOption pkgs "bluesky-relay" {}; + }; + config = mkIf cfg.enable { + systemd.services.bluesky-relay = { + description = "bluesky relay"; + after = ["network.target" "pds.service"]; + wantedBy = ["multi-user.target"]; + + serviceConfig = { + User = "relay"; + Group = "relay"; + StateDirectory = "relay"; + StateDirectoryMode = "0755"; + Environment = [ + "RELAY_ADMIN_PASSWORD=password" + "RELAY_PLC_HOST=https://plc.tngl.boltless.dev" + "DATABASE_URL=sqlite:///var/lib/relay/relay.sqlite" + "RELAY_IP_BIND=:2470" + "RELAY_PERSIST_DIR=/var/lib/relay" + "RELAY_DISABLE_REQUEST_CRAWL=0" + "RELAY_INITIAL_SEQ_NUMBER=1" + "RELAY_ALLOW_INSECURE_HOSTS=1" + ]; + ExecStart = "${getExe cfg.package} serve"; + Restart = "always"; + RestartSec = 5; + }; + }; + users = { + users.relay = { + group = "relay"; + isSystemUser = true; + }; + groups.relay = {}; + }; + }; + } diff --git a/nix/modules/did-method-plc.nix b/nix/modules/did-method-plc.nix new file mode 100644 index 00000000..94f24d0b --- /dev/null +++ b/nix/modules/did-method-plc.nix @@ -0,0 +1,76 @@ +{ + config, + pkgs, + lib, + ... +}: let + cfg = config.services.did-method-plc; +in + with lib; { + options.services.did-method-plc = { + enable = mkEnableOption "did-method-plc server"; + package = mkPackageOption pkgs "did-method-plc" {}; + }; + config = mkIf cfg.enable { + services.postgresql = { + enable = true; + package = pkgs.postgresql_14; + ensureDatabases = ["plc"]; + ensureUsers = [ + { + name = "pg"; + # ensurePermissions."DATABASE plc" = "ALL PRIVILEGES"; + } + ]; + authentication = '' + local all all trust + host all all 127.0.0.1/32 trust + ''; + }; + systemd.services.did-method-plc = { + description = "did-method-plc"; + + after = ["postgresql.service"]; + wants = ["postgresql.service"]; + wantedBy = ["multi-user.target"]; + + environment = let + db_creds_json = builtins.toJSON { + username = "pg"; + password = ""; + host = "127.0.0.1"; + port = 5432; + }; + in { + # TODO: inherit from config + DEBUG_MODE = "1"; + LOG_ENABLED = "true"; + LOG_LEVEL = "debug"; + LOG_DESTINATION = "1"; + ENABLE_MIGRATIONS = "true"; + DB_CREDS_JSON = db_creds_json; + DB_MIGRATE_CREDS_JSON = db_creds_json; + PLC_VERSION = "0.0.1"; + PORT = "8080"; + }; + + serviceConfig = { + ExecStart = getExe cfg.package; + User = "plc"; + Group = "plc"; + StateDirectory = "plc"; + StateDirectoryMode = "0755"; + Restart = "always"; + + # Hardening + }; + }; + users = { + users.plc = { + group = "plc"; + isSystemUser = true; + }; + groups.plc = {}; + }; + }; + } diff --git a/nix/pkgs/bluesky-jetstream.nix b/nix/pkgs/bluesky-jetstream.nix new file mode 100644 index 00000000..7a7a1d41 --- /dev/null +++ b/nix/pkgs/bluesky-jetstream.nix @@ -0,0 +1,20 @@ +{ + buildGoModule, + fetchFromGitHub, +}: +buildGoModule { + pname = "bluesky-jetstream"; + version = "0.1.0"; + src = fetchFromGitHub { + owner = "bluesky-social"; + repo = "jetstream"; + rev = "7d7efa58d7f14101a80ccc4f1085953948b7d5de"; + sha256 = "sha256-1e9SL/8gaDPMA4YZed51ffzgpkptbMd0VTbTTDbPTFw="; + }; + subPackages = ["cmd/jetstream"]; + vendorHash = "sha256-/21XJQH6fo9uPzlABUAbdBwt1O90odmppH6gXu2wkiQ="; + doCheck = false; + meta = { + mainProgram = "jetstream"; + }; +} diff --git a/nix/pkgs/bluesky-relay.nix b/nix/pkgs/bluesky-relay.nix new file mode 100644 index 00000000..5bc7cf2d --- /dev/null +++ b/nix/pkgs/bluesky-relay.nix @@ -0,0 +1,20 @@ +{ + buildGoModule, + fetchFromGitHub, +}: +buildGoModule { + pname = "bluesky-relay"; + version = "0.1.0"; + src = fetchFromGitHub { + owner = "boltlessengineer"; + repo = "indigo"; + rev = "7fe70a304d795b998f354d2b7b2050b909709c99"; + sha256 = "sha256-+h34x67cqH5t30+8rua53/ucvbn3BanrmH0Og3moHok="; + }; + subPackages = ["cmd/relay"]; + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; + doCheck = false; + meta = { + mainProgram = "relay"; + }; +} diff --git a/nix/pkgs/did-method-plc.nix b/nix/pkgs/did-method-plc.nix new file mode 100644 index 00000000..f71cbc6d --- /dev/null +++ b/nix/pkgs/did-method-plc.nix @@ -0,0 +1,65 @@ +# inspired by https://github.com/NixOS/nixpkgs/blob/333bfb7c258fab089a834555ea1c435674c459b4/pkgs/by-name/ga/gatsby-cli/package.nix +{ + lib, + stdenv, + fetchFromGitHub, + fetchYarnDeps, + yarnConfigHook, + yarnBuildHook, + nodejs, + makeBinaryWrapper, +}: +stdenv.mkDerivation (finalAttrs: { + pname = "did-method-plc"; + version = "0.0.1"; + + src = fetchFromGitHub { + owner = "did-method-plc"; + repo = "did-method-plc"; + rev = "158ba5535ac3da4fd4309954bde41deab0b45972"; + sha256 = "sha256-O5smubbrnTDMCvL6iRyMXkddr5G7YHxkQRVMRULHanQ="; + }; + postPatch = '' + # remove dd-trace dependency + sed -i '3d' packages/server/service/index.js + ''; + + yarnOfflineCache = fetchYarnDeps { + yarnLock = finalAttrs.src + "/yarn.lock"; + hash = "sha256-g8GzaAbWSnWwbQjJMV2DL5/ZlWCCX0sRkjjvX3tqU4Y="; + }; + + nativeBuildInputs = [ + yarnConfigHook + yarnBuildHook + nodejs + makeBinaryWrapper + ]; + yarnBuildScript = "lerna"; + yarnBuildFlags = [ + "run" + "build" + "--scope" + "@did-plc/server" + "--include-dependencies" + ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/node_modules/ + mv packages/ $out/lib/packages/ + mv node_modules/* $out/lib/node_modules/ + + makeWrapper ${lib.getExe nodejs} $out/bin/plc \ + --add-flags $out/lib/packages/server/service/index.js \ + --add-flags --enable-source-maps \ + --set NODE_PATH $out/lib/node_modules + + runHook postInstall + ''; + + meta = { + mainProgram = "plc"; + }; +}) diff --git a/nix/pkgs/tap.nix b/nix/pkgs/tap.nix new file mode 100644 index 00000000..604727ef --- /dev/null +++ b/nix/pkgs/tap.nix @@ -0,0 +1,20 @@ +{ + buildGoModule, + fetchFromGitHub, +}: +buildGoModule { + pname = "tap"; + version = "0.1.0"; + src = fetchFromGitHub { + owner = "bluesky-social"; + repo = "indigo"; + rev = "498ecb9693e8ae050f73234c86f340f51ad896a9"; + sha256 = "sha256-KASCdwkg/hlKBt7RTW3e3R5J3hqJkphoarFbaMgtN1k="; + }; + subPackages = ["cmd/tap"]; + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; + doCheck = false; + meta = { + mainProgram = "tap"; + }; +} -- 2.51.2 From fa470d07cd4ebb73e346f8fddab498cba2b019a7 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 17 Oct 2025 02:13:44 +0900 Subject: [PATCH 2/8] contrib,nix: local, sandboxed atmosphere infra Add sandboxed atmosphere environment for local testing. This new vm contains everything required to run local test appview including PLC, PDS, Jetstream (listening to single PDS), knot and spindle. I'm using my custom `tngl.boltless.dev` domain which resolves to `127.0.0.1` without any proxy. PLC: plc.tngl.boltless.dev PDS: pds.tngl.boltless.dev Relay: relay.tngl.boltless.dev Jetstream: jetstream.tngl.boltless.dev Knot: knot.tngl.boltless.dev Spindle: spindle.tngl.boltless.dev TLS is supported with caddy service running inside the vm. note: `pds.env` file here is hard copy to be used for contrib/scripts. note: upgraded pds package in order to set email settings Signed-off-by: Seongmin Lee --- contrib/certs/root.crt | 11 +++ contrib/example.env | 31 +++++++ contrib/pds.env | 12 +++ contrib/readme.md | 25 +++++ contrib/scripts/create-test-account.sh | 68 ++++++++++++++ contrib/scripts/setup-const-records.sh | 106 +++++++++++++++++++++ flake.nix | 2 +- nix/vm.nix | 122 +++++++++++++++++++++++++ 8 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 contrib/certs/root.crt create mode 100644 contrib/example.env create mode 100644 contrib/pds.env create mode 100644 contrib/readme.md create mode 100755 contrib/scripts/create-test-account.sh create mode 100755 contrib/scripts/setup-const-records.sh diff --git a/contrib/certs/root.crt b/contrib/certs/root.crt new file mode 100644 index 00000000..1d601326 --- /dev/null +++ b/contrib/certs/root.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBozCCAUmgAwIBAgIQRnYoKs3BuihlLFeydgURVzAKBggqhkjOPQQDAjAwMS4w +LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X +DTI2MDEwODEzNTk1MloXDTM1MTExNzEzNTk1MlowMDEuMCwGA1UEAxMlQ2FkZHkg +TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABCQlYShhxLaX8/ZP7rcBtD5xL4u3wYMe77JS/lRFjjpAUGmJPxUE +ctsNvukG1hU4MeLMSqAEIqFWjs8dQBxLjGSjRTBDMA4GA1UdDwEB/wQEAwIBBjAS +BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBQ7Mt/6izTOOXCSWDS6HrwrqMDB +vzAKBggqhkjOPQQDAgNIADBFAiEA9QAYIuHR5qsGJ1JMZnuAAQpEwaqewhUICsKO +e2fWj4ACICPgj9Kh9++8FH5eVyDI1AD/BLwmMmiaqs1ojZT7QJqb +-----END CERTIFICATE----- diff --git a/contrib/example.env b/contrib/example.env new file mode 100644 index 00000000..29d81276 --- /dev/null +++ b/contrib/example.env @@ -0,0 +1,31 @@ +# NOTE: put actual DIDs here +alice_did=did:plc:alice-did +tangled_did=did:plc:tangled-did + +#core +export TANGLED_DEV=true +export TANGLED_APPVIEW_HOST=127.0.0.1:3000 +# plc +export TANGLED_PLC_URL=https://plc.tngl.boltless.dev +# jetstream +export TANGLED_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe +# label +export TANGLED_LABEL_GFI=at://${tangled_did}/sh.tangled.label.definition/good-first-issue +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_GFI +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/assignee +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/documentation +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/duplicate +export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/wontfix + +# vm settings +export TANGLED_VM_PLC_URL=https://plc.tngl.boltless.dev +export TANGLED_VM_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe +export TANGLED_VM_KNOT_HOST=knot.tngl.boltless.dev +export TANGLED_VM_KNOT_OWNER=$alice_did +export TANGLED_VM_SPINDLE_HOST=spindle.tngl.boltless.dev +export TANGLED_VM_SPINDLE_OWNER=$alice_did + +if [ -n "${TANGLED_RESEND_API_KEY:-}" ] && [ -n "${TANGLED_RESEND_SENT_FROM:-}" ]; then + export TANGLED_VM_PDS_EMAIL_SMTP_URL=smtps://resend:$TANGLED_RESEND_API_KEY@smtp.resend.com:465/ + export TANGLED_VM_PDS_EMAIL_FROM_ADDRESS=$TANGLED_RESEND_SENT_FROM +fi diff --git a/contrib/pds.env b/contrib/pds.env new file mode 100644 index 00000000..2ed2d4b7 --- /dev/null +++ b/contrib/pds.env @@ -0,0 +1,12 @@ +LOG_ENABLED=true + +PDS_JWT_SECRET=8cae8bffcc73d9932819650791e4e89a +PDS_ADMIN_PASSWORD=d6a902588cd93bee1af83f924f60cfd3 +PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7 + +PDS_DATA_DIRECTORY=/pds +PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks + +PDS_DID_PLC_URL=http://localhost:8080 +PDS_HOSTNAME=pds.tngl.boltless.dev +PDS_PORT=3000 diff --git a/contrib/readme.md b/contrib/readme.md new file mode 100644 index 00000000..52783ee0 --- /dev/null +++ b/contrib/readme.md @@ -0,0 +1,25 @@ +# how to setup local appview dev environment + +Appview requires several microservices from knot and spindle to entire atproto infra. This test environment is implemented under nixos vm. + +1. copy `contrib/example.env` to `.env`, fill it and source it +2. run vm + ```bash + nix run --impure .#vm + ``` +3. trust the generated cert from host machine + ```bash + # for macos + sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain \ + ./nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/root.crt + ``` +4. create test accounts with valid emails (use [`create-test-account.sh`](./scripts/create-test-account.sh)) +5. create default labels (use [`setup-const-records`](./scripts/setup-const-records.sh)) +6. restart vm with correct owner-did + +for git-https, you should change your local git config: +``` +[http "https://knot.tngl.boltless.dev"] + sslCAPath = /Users/boltless/repo/tangled/nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/ +``` diff --git a/contrib/scripts/create-test-account.sh b/contrib/scripts/create-test-account.sh new file mode 100755 index 00000000..3e1668b7 --- /dev/null +++ b/contrib/scripts/create-test-account.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -o errexit +set -o nounset +set -o pipefail + +source "$(dirname "$0")/../pds.env" + +# PDS_HOSTNAME= +# PDS_ADMIN_PASSWORD= + +# curl a URL and fail if the request fails. +function curl_cmd_get { + curl --fail --silent --show-error "$@" +} + +# curl a URL and fail if the request fails. +function curl_cmd_post { + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} + +# curl a URL but do not fail if the request fails. +function curl_cmd_post_nofail { + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} + +USERNAME="${1:-}" + +if [[ "${USERNAME}" == "" ]]; then + read -p "Enter a username: " USERNAME +fi + +if [[ "${USERNAME}" == "" ]]; then + echo "ERROR: missing USERNAME parameter." >/dev/stderr + echo "Usage: $0 ${SUBCOMMAND} " >/dev/stderr + exit 1 +fi + +EMAIL=${USERNAME}@${PDS_HOSTNAME} + +PASSWORD="password" +INVITE_CODE="$(curl_cmd_post \ + --user "admin:${PDS_ADMIN_PASSWORD}" \ + --data '{"useCount": 1}' \ + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code' +)" +RESULT="$(curl_cmd_post_nofail \ + --data "{\"email\":\"${EMAIL}\", \"handle\":\"${USERNAME}.${PDS_HOSTNAME}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${INVITE_CODE}\"}" \ + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createAccount" +)" + +DID="$(echo $RESULT | jq --raw-output '.did')" +if [[ "${DID}" != did:* ]]; then + ERR="$(echo ${RESULT} | jq --raw-output '.message')" + echo "ERROR: ${ERR}" >/dev/stderr + echo "Usage: $0 " >/dev/stderr + exit 1 +fi + +echo +echo "Account created successfully!" +echo "-----------------------------" +echo "Handle : ${USERNAME}.${PDS_HOSTNAME}" +echo "DID : ${DID}" +echo "Password : ${PASSWORD}" +echo "-----------------------------" +echo "This is a test account with an insecure password." +echo "Make sure it's only used for development." +echo diff --git a/contrib/scripts/setup-const-records.sh b/contrib/scripts/setup-const-records.sh new file mode 100755 index 00000000..e3887db2 --- /dev/null +++ b/contrib/scripts/setup-const-records.sh @@ -0,0 +1,106 @@ +#!/bin/bash +set -o errexit +set -o nounset +set -o pipefail + +source "$(dirname "$0")/../pds.env" + +# PDS_HOSTNAME= + +# curl a URL and fail if the request fails. +function curl_cmd_get { + curl --fail --silent --show-error "$@" +} + +# curl a URL and fail if the request fails. +function curl_cmd_post { + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} + +# curl a URL but do not fail if the request fails. +function curl_cmd_post_nofail { + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} + +USERNAME="${1:-}" + +if [[ "${USERNAME}" == "" ]]; then + read -p "Enter a username: " USERNAME +fi + +if [[ "${USERNAME}" == "" ]]; then + echo "ERROR: missing USERNAME parameter." >/dev/stderr + echo "Usage: $0 ${SUBCOMMAND} " >/dev/stderr + exit 1 +fi + +SESS_RESULT="$(curl_cmd_post \ + --data "$(cat < Date: Tue, 11 Nov 2025 03:46:11 +0900 Subject: [PATCH 3/8] wip: fix jetstream client Signed-off-by: Seongmin Lee --- jetstream/jetstream.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jetstream/jetstream.go b/jetstream/jetstream.go index fc4480fd..4de9fcfd 100644 --- a/jetstream/jetstream.go +++ b/jetstream/jetstream.go @@ -159,8 +159,9 @@ func (j *JetstreamClient) connectAndRead(ctx context.Context) { j.cancelMu.Unlock() if err := j.client.ConnectAndRead(connCtx, cursor); err != nil { - l.Error("error reading jetstream", "error", err) + l.Error("error reading jetstream, retry in 3s", "error", err) cancel() + time.Sleep(3 * time.Second) continue } -- 2.51.2 From fab5f2c752a716f292e548559a768328ec36fedc Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 24 Oct 2025 01:33:08 +0900 Subject: [PATCH 4/8] private: appview/pages: apply monospace font for all textarea monospace font for textarea in dev app is so common that we can just apply it as an opt-out style for all textareas Signed-off-by: Seongmin Lee --- appview/pages/templates/strings/fragments/form.html | 2 +- input.css | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/appview/pages/templates/strings/fragments/form.html b/appview/pages/templates/strings/fragments/form.html index 30d547d9..bca1a43d 100644 --- a/appview/pages/templates/strings/fragments/form.html +++ b/appview/pages/templates/strings/fragments/form.html @@ -31,7 +31,7 @@ name="content" id="content-textarea" wrap="off" - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" rows="20" spellcheck="false" placeholder="Paste your string here!" diff --git a/input.css b/input.css index 70c6b78a..fb3f6119 100644 --- a/input.css +++ b/input.css @@ -99,6 +99,9 @@ border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500; } + textarea { + @apply font-mono; + } details summary::-webkit-details-marker { display: none; } -- 2.51.2 From e142ecfe845b6d4d1227fcb1ec6112a79a5ad8c3 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 14 Nov 2025 13:24:53 +0900 Subject: [PATCH 5/8] lexicons: add general `sh.tangled.comment` lexicon Signed-off-by: Seongmin Lee --- api/tangled/cbor_gen.go | 416 ++++++++++++++++++++++++++++++++++ api/tangled/tangledcomment.go | 27 +++ cmd/cborgen/cborgen.go | 1 + lexicons/comment/comment.json | 51 +++++ 4 files changed, 495 insertions(+) create mode 100644 api/tangled/tangledcomment.go create mode 100644 lexicons/comment/comment.json diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index a4016bf5..a1f9f858 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -604,6 +604,422 @@ func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *Comment) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 7 + + if t.Mentions == nil { + fieldCount-- + } + + if t.References == nil { + fieldCount-- + } + + if t.ReplyTo == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Body (string) (string) + if len("body") > 1000000 { + return xerrors.Errorf("Value in field \"body\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { + return err + } + if _, err := cw.WriteString(string("body")); err != nil { + return err + } + + if len(t.Body) > 1000000 { + return xerrors.Errorf("Value in field t.Body was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Body)); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.comment"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sh.tangled.comment")); err != nil { + return err + } + + // t.ReplyTo (string) (string) + if t.ReplyTo != nil { + + if len("replyTo") > 1000000 { + return xerrors.Errorf("Value in field \"replyTo\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { + return err + } + if _, err := cw.WriteString(string("replyTo")); err != nil { + return err + } + + if t.ReplyTo == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.ReplyTo) > 1000000 { + return xerrors.Errorf("Value in field t.ReplyTo was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { + return err + } + } + } + + // t.Subject (string) (string) + if len("subject") > 1000000 { + return xerrors.Errorf("Value in field \"subject\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { + return err + } + if _, err := cw.WriteString(string("subject")); err != nil { + return err + } + + if len(t.Subject) > 1000000 { + return xerrors.Errorf("Value in field t.Subject was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Subject)); err != nil { + return err + } + + // t.Mentions ([]string) (slice) + if t.Mentions != nil { + + if len("mentions") > 1000000 { + return xerrors.Errorf("Value in field \"mentions\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { + return err + } + if _, err := cw.WriteString(string("mentions")); err != nil { + return err + } + + if len(t.Mentions) > 8192 { + return xerrors.Errorf("Slice value in field t.Mentions was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { + return err + } + for _, v := range t.Mentions { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + + // t.CreatedAt (string) (string) + if len("createdAt") > 1000000 { + return xerrors.Errorf("Value in field \"createdAt\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { + return err + } + if _, err := cw.WriteString(string("createdAt")); err != nil { + return err + } + + if len(t.CreatedAt) > 1000000 { + return xerrors.Errorf("Value in field t.CreatedAt was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { + return err + } + + // t.References ([]string) (slice) + if t.References != nil { + + if len("references") > 1000000 { + return xerrors.Errorf("Value in field \"references\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { + return err + } + if _, err := cw.WriteString(string("references")); err != nil { + return err + } + + if len(t.References) > 8192 { + return xerrors.Errorf("Slice value in field t.References was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { + return err + } + for _, v := range t.References { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + return nil +} + +func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { + *t = Comment{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("Comment: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 10) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Body (string) (string) + case "body": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Body = string(sval) + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.ReplyTo (string) (string) + case "replyTo": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.ReplyTo = (*string)(&sval) + } + } + // t.Subject (string) (string) + case "subject": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Subject = string(sval) + } + // t.Mentions ([]string) (slice) + case "mentions": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Mentions: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Mentions = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Mentions[i] = string(sval) + } + + } + } + // t.CreatedAt (string) (string) + case "createdAt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.CreatedAt = string(sval) + } + // t.References ([]string) (slice) + case "references": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.References: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.References = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.References[i] = string(sval) + } + + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} func (t *FeedReaction) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) diff --git a/api/tangled/tangledcomment.go b/api/tangled/tangledcomment.go new file mode 100644 index 00000000..5b759e23 --- /dev/null +++ b/api/tangled/tangledcomment.go @@ -0,0 +1,27 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.comment + +import ( + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + CommentNSID = "sh.tangled.comment" +) + +func init() { + util.RegisterType("sh.tangled.comment", &Comment{}) +} // +// RECORDTYPE: Comment +type Comment struct { + LexiconTypeID string `json:"$type,const=sh.tangled.comment" cborgen:"$type,const=sh.tangled.comment"` + Body string `json:"body" cborgen:"body"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` + References []string `json:"references,omitempty" cborgen:"references,omitempty"` + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` + Subject string `json:"subject" cborgen:"subject"` +} diff --git a/cmd/cborgen/cborgen.go b/cmd/cborgen/cborgen.go index 395fc4c7..edd77d78 100644 --- a/cmd/cborgen/cborgen.go +++ b/cmd/cborgen/cborgen.go @@ -15,6 +15,7 @@ func main() { "api/tangled/cbor_gen.go", "tangled", tangled.ActorProfile{}, + tangled.Comment{}, tangled.FeedReaction{}, tangled.FeedStar{}, tangled.GitRefUpdate{}, diff --git a/lexicons/comment/comment.json b/lexicons/comment/comment.json new file mode 100644 index 00000000..f1e47837 --- /dev/null +++ b/lexicons/comment/comment.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "sh.tangled.comment", + "needsCbor": true, + "needsType": true, + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "subject", + "body", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri" + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "replyTo": { + "type": "string", + "format": "at-uri" + }, + "mentions": { + "type": "array", + "items": { + "type": "string", + "format": "did" + } + }, + "references": { + "type": "array", + "items": { + "type": "string", + "format": "at-uri" + } + } + } + } + } + } +} -- 2.51.2 From 345fbd28fd04d4d5f59f480d06f925cb23ec58a4 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 14 Nov 2025 13:24:53 +0900 Subject: [PATCH 6/8] appview: replace `PullComment` to `Comment` Including db migration to migrate `issue_comments` and `pull_comments` to unified `comments` table. Signed-off-by: Seongmin Lee --- appview/db/comments.go | 202 +++++++++++++++++++ appview/db/db.go | 81 ++++++++ appview/db/pulls.go | 127 +----------- appview/db/reference.go | 15 +- appview/ingester.go | 71 +++++++ appview/models/comment.go | 138 +++++++++++++ appview/models/pull.go | 30 +-- appview/notify/db/db.go | 17 +- appview/notify/merged_notifier.go | 2 +- appview/notify/notifier.go | 4 +- appview/notify/posthog/notifier.go | 7 +- appview/pages/templates/repo/pulls/pull.html | 9 +- appview/pulls/opengraph.go | 2 +- appview/pulls/pulls.go | 50 ++--- appview/state/state.go | 1 + 15 files changed, 557 insertions(+), 199 deletions(-) create mode 100644 appview/db/comments.go create mode 100644 appview/models/comment.go diff --git a/appview/db/comments.go b/appview/db/comments.go new file mode 100644 index 00000000..3cd9cd18 --- /dev/null +++ b/appview/db/comments.go @@ -0,0 +1,202 @@ +package db + +import ( + "database/sql" + "fmt" + "maps" + "slices" + "sort" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/models" + "tangled.org/core/orm" +) + +func PutComment(tx *sql.Tx, c *models.Comment) error { + if c.Collection == "" { + c.Collection = tangled.CommentNSID + } + result, err := tx.Exec( + `insert into comments ( + did, + collection, + rkey, + subject_at, + reply_to, + body, + pull_submission_id, + created + ) + values (?, ?, ?, ?, ?, ?, ?, ?) + on conflict(did, collection, rkey) do update set + subject_at = excluded.subject_at, + reply_to = excluded.reply_to, + body = excluded.body, + edited = case + when + comments.subject_at != excluded.subject_at + or comments.body != excluded.body + or comments.reply_to != excluded.reply_to + then ? + else comments.edited + end`, + c.Did, + c.Collection, + c.Rkey, + c.Subject, + c.ReplyTo, + c.Body, + c.PullSubmissionId, + c.Created.Format(time.RFC3339), + time.Now().Format(time.RFC3339), + ) + if err != nil { + return err + } + + c.Id, err = result.LastInsertId() + if err != nil { + return err + } + + if err := putReferences(tx, c.AtUri(), c.References); err != nil { + return fmt.Errorf("put reference_links: %w", err) + } + + return nil +} + +func DeleteComments(e Execer, filters ...orm.Filter) error { + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(`update comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) + + _, err := e.Exec(query, args...) + return err +} + +func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { + commentMap := make(map[string]*models.Comment) + + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(` + select + id, + did, + collection, + rkey, + subject_at, + reply_to, + body, + pull_submission_id, + created, + edited, + deleted + from + comments + %s + `, whereClause) + + rows, err := e.Query(query, args...) + if err != nil { + return nil, err + } + + for rows.Next() { + var comment models.Comment + var created string + var edited, deleted, replyTo sql.Null[string] + err := rows.Scan( + &comment.Id, + &comment.Did, + &comment.Collection, + &comment.Rkey, + &comment.Subject, + &replyTo, + &comment.Body, + &comment.PullSubmissionId, + &created, + &edited, + &deleted, + ) + if err != nil { + return nil, err + } + + if t, err := time.Parse(time.RFC3339, created); err == nil { + comment.Created = t + } + + if edited.Valid { + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { + comment.Edited = &t + } + } + + if deleted.Valid { + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { + comment.Deleted = &t + } + } + + if replyTo.Valid { + rt := syntax.ATURI(replyTo.V) + comment.ReplyTo = &rt + } + + atUri := comment.AtUri().String() + commentMap[atUri] = &comment + } + + if err := rows.Err(); err != nil { + return nil, err + } + defer rows.Close() + + // collect references from each comments + commentAts := slices.Collect(maps.Keys(commentMap)) + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) + if err != nil { + return nil, fmt.Errorf("failed to query reference_links: %w", err) + } + for commentAt, references := range allReferencs { + if comment, ok := commentMap[commentAt.String()]; ok { + comment.References = references + } + } + + var comments []models.Comment + for _, c := range commentMap { + comments = append(comments, *c) + } + + sort.Slice(comments, func(i, j int) bool { + return comments[i].Created.Before(comments[j].Created) + }) + + return comments, nil +} diff --git a/appview/db/db.go b/appview/db/db.go index e6ef3143..19fe160e 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1181,6 +1181,87 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { return err }) + orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { + _, err := tx.Exec(` + drop table if exists comments; + + create table comments ( + -- identifiers + id integer primary key autoincrement, + did text not null, + collection text not null default 'sh.tangled.comment', + rkey text not null, + at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored, + + -- at identifiers + subject_at text not null, + reply_to text, -- at_uri of parent comment + + pull_submission_id integer, -- dirty fix until we atprotate the pull-rounds + + -- content + body text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + edited text, + deleted text, + + -- constraints + unique(did, collection, rkey) + ); + + insert into comments ( + did, + collection, + rkey, + subject_at, + reply_to, + body, + created, + edited, + deleted + ) + select + did, + 'sh.tangled.repo.issue.comment', + rkey, + issue_at, + reply_to, + body, + created, + edited, + deleted + from issue_comments + where rkey is not null; + + insert into comments ( + did, + collection, + rkey, + subject_at, + pull_submission_id, + body, + created + ) + select + c.owner_did, + 'sh.tangled.repo.pull.comment', + substr( + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey + instr( + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey + '/' + ) + 1 + ), -- rkey + p.at_uri, + c.submission_id, + c.body, + c.created + from pull_comments c + join pulls p on c.repo_at = p.repo_at and c.pull_id = p.pull_id; + `) + return err + }) + return &DB{ db, logger, diff --git a/appview/db/pulls.go b/appview/db/pulls.go index a4a78a27..fa811d00 100644 --- a/appview/db/pulls.go +++ b/appview/db/pulls.go @@ -391,15 +391,17 @@ func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*mo return nil, err } - // Get comments for all submissions using GetPullComments + // Get comments for all submissions using GetComments submissionIds := slices.Collect(maps.Keys(submissionMap)) - comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) + comments, err := GetComments(e, orm.FilterIn("pull_submission_id", submissionIds)) if err != nil { return nil, fmt.Errorf("failed to get pull comments: %w", err) } for _, comment := range comments { - if submission, ok := submissionMap[comment.SubmissionId]; ok { - submission.Comments = append(submission.Comments, comment) + if comment.PullSubmissionId != nil { + if submission, ok := submissionMap[*comment.PullSubmissionId]; ok { + submission.Comments = append(submission.Comments, comment) + } } } @@ -419,96 +421,6 @@ func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*mo return m, nil } -func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(` - select - id, - pull_id, - submission_id, - repo_at, - owner_did, - comment_at, - body, - created - from - pull_comments - %s - order by - created asc - `, whereClause) - - rows, err := e.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - commentMap := make(map[string]*models.PullComment) - for rows.Next() { - var comment models.PullComment - var createdAt string - err := rows.Scan( - &comment.ID, - &comment.PullId, - &comment.SubmissionId, - &comment.RepoAt, - &comment.OwnerDid, - &comment.CommentAt, - &comment.Body, - &createdAt, - ) - if err != nil { - return nil, err - } - - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { - comment.Created = t - } - - atUri := comment.AtUri().String() - commentMap[atUri] = &comment - } - - if err := rows.Err(); err != nil { - return nil, err - } - - // collect references for each comments - commentAts := slices.Collect(maps.Keys(commentMap)) - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) - if err != nil { - return nil, fmt.Errorf("failed to query reference_links: %w", err) - } - for commentAt, references := range allReferencs { - if comment, ok := commentMap[commentAt.String()]; ok { - comment.References = references - } - } - - var comments []models.PullComment - for _, c := range commentMap { - comments = append(comments, *c) - } - - sort.Slice(comments, func(i, j int) bool { - return comments[i].Created.Before(comments[j].Created) - }) - - return comments, nil -} - // timeframe here is directly passed into the sql query filter, and any // timeframe in the past should be negative; e.g.: "-3 months" func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { @@ -585,33 +497,6 @@ func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) return pulls, nil } -func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { - query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` - res, err := tx.Exec( - query, - comment.OwnerDid, - comment.RepoAt, - comment.SubmissionId, - comment.CommentAt, - comment.PullId, - comment.Body, - ) - if err != nil { - return 0, err - } - - i, err := res.LastInsertId() - if err != nil { - return 0, err - } - - if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { - return 0, fmt.Errorf("put reference_links: %w", err) - } - - return i, nil -} - func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error { _, err := e.Exec( `update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`, diff --git a/appview/db/reference.go b/appview/db/reference.go index 0cb1fe3f..b48d6dfd 100644 --- a/appview/db/reference.go +++ b/appview/db/reference.go @@ -124,8 +124,7 @@ func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATU values %s ) select - p.owner_did, p.rkey, - c.comment_at + p.owner_did, p.rkey, c.at_uri from input inp join repos r on r.did = inp.owner_did @@ -133,9 +132,9 @@ func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATU join pulls p on p.repo_at = r.at_uri and p.pull_id = inp.pull_id - left join pull_comments c + left join comments c on inp.comment_id is not null - and c.repo_at = r.at_uri and c.pull_id = p.pull_id + and c.subject_at = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) and c.id = inp.comment_id `, strings.Join(vals, ","), @@ -293,7 +292,7 @@ func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, er return nil, fmt.Errorf("get pull backlinks: %w", err) } backlinks = append(backlinks, ls...) - ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) if err != nil { return nil, fmt.Errorf("get pull_comment backlinks: %w", err) } @@ -428,15 +427,15 @@ func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichRefe if len(aturis) == 0 { return nil, nil } - filter := orm.FilterIn("c.comment_at", aturis) + filter := orm.FilterIn("c.at_uri", aturis) rows, err := e.Query( fmt.Sprintf( `select r.did, r.name, p.pull_id, c.id, p.title, p.state from repos r join pulls p on r.at_uri = p.repo_at - join pull_comments c - on r.at_uri = c.repo_at and p.pull_id = c.pull_id + join comments c + on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_at where %s`, filter.Condition(), ), diff --git a/appview/ingester.go b/appview/ingester.go index 338d40b0..f8c44dde 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -79,6 +79,8 @@ func (i *Ingester) Ingest() processFunc { err = i.ingestString(e) case tangled.RepoIssueNSID: err = i.ingestIssue(ctx, e) + case tangled.CommentNSID: + err = i.ingestComment(e) case tangled.RepoIssueCommentNSID: err = i.ingestIssueComment(e) case tangled.LabelDefinitionNSID: @@ -934,6 +936,75 @@ func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { return nil } +func (i *Ingester) ingestComment(e *jmodels.Event) error { + did := e.Did + rkey := e.Commit.RKey + + var err error + + l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) + l.Info("ingesting record") + + ddb, ok := i.Db.Execer.(*db.DB) + if !ok { + return fmt.Errorf("failed to index issue comment record, invalid db cast") + } + + switch e.Commit.Operation { + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + raw := json.RawMessage(e.Commit.Record) + record := tangled.Comment{} + err = json.Unmarshal(raw, &record) + if err != nil { + return fmt.Errorf("invalid record: %w", err) + } + + comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), record) + if err != nil { + return fmt.Errorf("failed to parse comment from record: %w", err) + } + + // TODO: ingest pull comments + // we aren't ingesting pull comments yet because pull itself isn't fully atprotated. + // so we cannot know which round this comment is pointing to + if comment.Subject.Collection().String() == tangled.RepoPullNSID { + l.Info("skip ingesting pull comments") + return nil + } + + if err := comment.Validate(); err != nil { + return fmt.Errorf("failed to validate comment: %w", err) + } + + tx, err := ddb.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer tx.Rollback() + + err = db.PutComment(tx, comment) + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + + return tx.Commit() + + case jmodels.CommitOperationDelete: + if err := db.DeleteComments( + ddb, + orm.FilterEq("did", did), + orm.FilterEq("collection", e.Commit.Collection), + orm.FilterEq("rkey", rkey), + ); err != nil { + return fmt.Errorf("failed to delete comment record: %w", err) + } + + return nil + } + + return nil +} + func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { did := e.Did rkey := e.Commit.RKey diff --git a/appview/models/comment.go b/appview/models/comment.go new file mode 100644 index 00000000..f130e0b4 --- /dev/null +++ b/appview/models/comment.go @@ -0,0 +1,138 @@ +package models + +import ( + "fmt" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/whyrusleeping/cbor-gen" + "tangled.org/core/api/tangled" +) + +type Comment struct { + Id int64 + Did syntax.DID + Collection syntax.NSID + Rkey string + Subject syntax.ATURI + ReplyTo *syntax.ATURI + Body string + Created time.Time + Edited *time.Time + Deleted *time.Time + Mentions []syntax.DID + References []syntax.ATURI + PullSubmissionId *int +} + +func (c *Comment) AtUri() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey)) +} + +func (c *Comment) AsRecord() typegen.CBORMarshaler { + mentions := make([]string, len(c.Mentions)) + for i, did := range c.Mentions { + mentions[i] = string(did) + } + references := make([]string, len(c.References)) + for i, uri := range c.References { + references[i] = string(uri) + } + var replyTo *string + if c.ReplyTo != nil { + replyToStr := c.ReplyTo.String() + replyTo = &replyToStr + } + switch c.Collection { + case tangled.RepoIssueCommentNSID: + return &tangled.RepoIssueComment{ + Issue: c.Subject.String(), + Body: c.Body, + CreatedAt: c.Created.Format(time.RFC3339), + ReplyTo: replyTo, + Mentions: mentions, + References: references, + } + case tangled.RepoPullCommentNSID: + return &tangled.RepoPullComment{ + Pull: c.Subject.String(), + Body: c.Body, + CreatedAt: c.Created.Format(time.RFC3339), + Mentions: mentions, + References: references, + } + default: // default to CommentNSID + return &tangled.Comment{ + Subject: c.Subject.String(), + Body: c.Body, + CreatedAt: c.Created.Format(time.RFC3339), + ReplyTo: replyTo, + Mentions: mentions, + References: references, + } + } +} + +func (c *Comment) IsTopLevel() bool { + return c.ReplyTo == nil +} + +func (c *Comment) IsReply() bool { + return c.ReplyTo != nil +} + +func (c *Comment) Validate() error { + // TODO: sanitize the body and then trim space + if sb := strings.TrimSpace(c.Body); sb == "" { + return fmt.Errorf("body is empty after HTML sanitization") + } + + // if it's for PR, PullSubmissionId should not be nil + if c.Subject.Collection().String() == tangled.RepoPullNSID { + if c.PullSubmissionId == nil { + return fmt.Errorf("PullSubmissionId should not be nil") + } + } + return nil +} + +func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, record tangled.Comment) (*Comment, error) { + created, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + created = time.Now() + } + + if _, err = syntax.ParseATURI(record.Subject); err != nil { + return nil, err + } + + i := record + mentions := make([]syntax.DID, len(record.Mentions)) + for i, did := range record.Mentions { + mentions[i] = syntax.DID(did) + } + references := make([]syntax.ATURI, len(record.References)) + for i, uri := range i.References { + references[i] = syntax.ATURI(uri) + } + var replyTo *syntax.ATURI + if record.ReplyTo != nil { + replyToAtUri := syntax.ATURI(*record.ReplyTo) + replyTo = &replyToAtUri + } + + comment := Comment{ + Did: did, + Collection: tangled.CommentNSID, + Rkey: rkey.String(), + Body: record.Body, + Subject: syntax.ATURI(record.Subject), + ReplyTo: replyTo, + Created: created, + Mentions: mentions, + References: references, + } + + return &comment, nil +} diff --git a/appview/models/pull.go b/appview/models/pull.go index 4a2e0ee7..37f3bc85 100644 --- a/appview/models/pull.go +++ b/appview/models/pull.go @@ -138,39 +138,13 @@ type PullSubmission struct { RoundNumber int Patch string Combined string - Comments []PullComment + Comments []Comment SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs // meta Created time.Time } -type PullComment struct { - // ids - ID int - PullId int - SubmissionId int - - // at ids - RepoAt string - OwnerDid string - CommentAt string - - // content - Body string - - // meta - Mentions []syntax.DID - References []syntax.ATURI - - // meta - Created time.Time -} - -func (p *PullComment) AtUri() syntax.ATURI { - return syntax.ATURI(p.CommentAt) -} - func (p *Pull) TotalComments() int { total := 0 for _, s := range p.Submissions { @@ -279,7 +253,7 @@ func (s *PullSubmission) Participants() []string { addParticipant(s.PullAt.Authority().String()) for _, c := range s.Comments { - addParticipant(c.OwnerDid) + addParticipant(c.Did.String()) } return participants diff --git a/appview/notify/db/db.go b/appview/notify/db/db.go index 591f02ea..047ac77e 100644 --- a/appview/notify/db/db.go +++ b/appview/notify/db/db.go @@ -260,17 +260,22 @@ func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { ) } -func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { - pull, err := db.GetPull(n.db, - syntax.ATURI(comment.RepoAt), - comment.PullId, +func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { + pulls, err := db.GetPulls(n.db, + orm.FilterEq("owner_did", comment.Subject.Authority()), + orm.FilterEq("rkey", comment.Subject.RecordKey()), ) if err != nil { log.Printf("NewPullComment: failed to get pulls: %v", err) return } + if len(pulls) == 0 { + log.Printf("NewPullComment: no pull found for %s", comment.Subject) + return + } + pull := pulls[0] - repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) if err != nil { log.Printf("NewPullComment: failed to get repos: %v", err) return @@ -288,7 +293,7 @@ func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.P recipients.Remove(m) } - actorDid := syntax.DID(comment.OwnerDid) + actorDid := comment.Did eventType := models.NotificationTypePullCommented entityType := "pull" entityId := pull.AtUri().String() diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go index fbdb1646..bb99b342 100644 --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -81,7 +81,7 @@ func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { m.fanout("NewPull", ctx, pull) } -func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { +func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { m.fanout("NewPullComment", ctx, comment, mentions) } diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go index 72462ce0..45e7e11c 100644 --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -22,7 +22,7 @@ type Notifier interface { DeleteFollow(ctx context.Context, follow *models.Follow) NewPull(ctx context.Context, pull *models.Pull) - NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) + NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) UpdateProfile(ctx context.Context, profile *models.Profile) @@ -52,7 +52,7 @@ func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} -func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { +func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.Comment, mentions []syntax.DID) { } func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} diff --git a/appview/notify/posthog/notifier.go b/appview/notify/posthog/notifier.go index 2679d946..8b2accf3 100644 --- a/appview/notify/posthog/notifier.go +++ b/appview/notify/posthog/notifier.go @@ -86,13 +86,12 @@ func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { } } -func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { +func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { err := n.client.Enqueue(posthog.Capture{ - DistinctId: comment.OwnerDid, + DistinctId: comment.Did.String(), Event: "new_pull_comment", Properties: posthog.Properties{ - "repo_at": comment.RepoAt, - "pull_id": comment.PullId, + "pull_at": comment.Subject, "mentions": mentions, }, }) diff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html index b13d15c2..2f2c5c0e 100644 --- a/appview/pages/templates/repo/pulls/pull.html +++ b/appview/pages/templates/repo/pulls/pull.html @@ -561,19 +561,20 @@ {{ end }} {{ define "submissionComment" }} -
+
- {{ template "user/fragments/picLink" (list .OwnerDid "size-8") }} + {{ template "user/fragments/picLink" (list .Did.String "size-8") }}
- {{ $handle := resolve .OwnerDid }} + {{ $handle := resolve .Did.String }} {{ $handle }} - + + {{ template "repo/fragments/time" .Created }} {{ template "repo/fragments/shortTime" .Created }}
diff --git a/appview/pulls/opengraph.go b/appview/pulls/opengraph.go index 9153eea5..24464b9a 100644 --- a/appview/pulls/opengraph.go +++ b/appview/pulls/opengraph.go @@ -277,7 +277,7 @@ func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { } // Get comment count from database - comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) + comments, err := db.GetComments(s.db, orm.FilterEq("subject_at", pull.AtUri())) if err != nil { log.Printf("failed to get pull comments: %v", err) } diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index feef02bb..ada50cb4 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -727,7 +727,23 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback() - createdAt := time.Now().Format(time.RFC3339) + comment := models.Comment{ + Did: syntax.DID(user.Active.Did), + Collection: tangled.CommentNSID, + Rkey: tid.TID(), + Subject: pull.AtUri(), + ReplyTo: nil, + Body: body, + Created: time.Now(), + Mentions: mentions, + References: references, + PullSubmissionId: &pull.Submissions[roundNumber].ID, + } + if err = comment.Validate(); err != nil { + log.Println("failed to validate comment", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return + } client, err := s.oauth.AuthorizedClient(r) if err != nil { @@ -735,16 +751,13 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { s.pages.Notice(w, "pull-comment", "Failed to create comment.") return } - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoPullCommentNSID, - Repo: user.Active.Did, - Rkey: tid.TID(), + + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: comment.Collection.String(), + Repo: comment.Did.String(), + Rkey: comment.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.RepoPullComment{ - Pull: pull.AtUri().String(), - Body: body, - CreatedAt: createdAt, - }, + Val: comment.AsRecord(), }, }) if err != nil { @@ -753,19 +766,8 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { return } - comment := &models.PullComment{ - OwnerDid: user.Active.Did, - RepoAt: f.RepoAt().String(), - PullId: pull.PullId, - Body: body, - CommentAt: atResp.Uri, - SubmissionId: pull.Submissions[roundNumber].ID, - Mentions: mentions, - References: references, - } - // Create the pull comment in the database with the commentAt field - commentId, err := db.NewPullComment(tx, comment) + err = db.PutComment(tx, &comment) if err != nil { log.Println("failed to create pull comment", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") @@ -779,10 +781,10 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { return } - s.notifier.NewPullComment(r.Context(), comment, mentions) + s.notifier.NewPullComment(r.Context(), &comment, mentions) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) return } } diff --git a/appview/state/state.go b/appview/state/state.go index 4f7b5b22..fea3fb06 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -118,6 +118,7 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { tangled.StringNSID, tangled.RepoIssueNSID, tangled.RepoIssueCommentNSID, + tangled.CommentNSID, tangled.LabelDefinitionNSID, tangled.LabelOpNSID, }, -- 2.51.2 From 0cdf333ae8a7db5aff21d9c554fd429bd70348f4 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Tue, 9 Dec 2025 00:06:33 +0900 Subject: [PATCH 7/8] appview: replace `IssueComment` to `Comment` Signed-off-by: Seongmin Lee --- appview/db/issues.go | 192 +----------------- appview/db/reference.go | 37 ++-- appview/ingester.go | 21 +- appview/issues/issues.go | 74 +++---- appview/models/issue.go | 97 +-------- appview/notify/db/db.go | 8 +- appview/notify/merged_notifier.go | 2 +- appview/notify/notifier.go | 4 +- appview/notify/posthog/notifier.go | 6 +- appview/pages/pages.go | 8 +- .../repo/issues/fragments/commentList.html | 4 +- .../issues/fragments/issueCommentHeader.html | 4 +- appview/validator/issue.go | 27 --- 13 files changed, 98 insertions(+), 386 deletions(-) diff --git a/appview/db/issues.go b/appview/db/issues.go index 7ab3f6aa..09e96fec 100644 --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -100,7 +100,7 @@ func updateIssue(tx *sql.Tx, issue *models.Issue) error { } func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { - issueMap := make(map[string]*models.Issue) // at-uri -> issue + issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue var conditions []string var args []any @@ -196,8 +196,7 @@ func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ( } } - atUri := issue.AtUri().String() - issueMap[atUri] = &issue + issueMap[issue.AtUri()] = &issue } // collect reverse repos @@ -229,12 +228,12 @@ func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ( // collect comments issueAts := slices.Collect(maps.Keys(issueMap)) - comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) + comments, err := GetComments(e, orm.FilterIn("subject_at", issueAts)) if err != nil { return nil, fmt.Errorf("failed to query comments: %w", err) } for i := range comments { - issueAt := comments[i].IssueAt + issueAt := comments[i].Subject if issue, ok := issueMap[issueAt]; ok { issue.Comments = append(issue.Comments, comments[i]) } @@ -246,7 +245,7 @@ func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ( return nil, fmt.Errorf("failed to query labels: %w", err) } for issueAt, labels := range allLabels { - if issue, ok := issueMap[issueAt.String()]; ok { + if issue, ok := issueMap[issueAt]; ok { issue.Labels = labels } } @@ -257,7 +256,7 @@ func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ( return nil, fmt.Errorf("failed to query reference_links: %w", err) } for issueAt, references := range allReferencs { - if issue, ok := issueMap[issueAt.String()]; ok { + if issue, ok := issueMap[issueAt]; ok { issue.References = references } } @@ -295,185 +294,6 @@ func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { return GetIssuesPaginated(e, pagination.Page{}, filters...) } -func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { - result, err := tx.Exec( - `insert into issue_comments ( - did, - rkey, - issue_at, - body, - reply_to, - created, - edited - ) - values (?, ?, ?, ?, ?, ?, null) - on conflict(did, rkey) do update set - issue_at = excluded.issue_at, - body = excluded.body, - edited = case - when - issue_comments.issue_at != excluded.issue_at - or issue_comments.body != excluded.body - or issue_comments.reply_to != excluded.reply_to - then ? - else issue_comments.edited - end`, - c.Did, - c.Rkey, - c.IssueAt, - c.Body, - c.ReplyTo, - c.Created.Format(time.RFC3339), - time.Now().Format(time.RFC3339), - ) - if err != nil { - return 0, err - } - - id, err := result.LastInsertId() - if err != nil { - return 0, err - } - - if err := putReferences(tx, c.AtUri(), c.References); err != nil { - return 0, fmt.Errorf("put reference_links: %w", err) - } - - return id, nil -} - -func DeleteIssueComments(e Execer, filters ...orm.Filter) error { - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) - - _, err := e.Exec(query, args...) - return err -} - -func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { - commentMap := make(map[string]*models.IssueComment) - - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(` - select - id, - did, - rkey, - issue_at, - reply_to, - body, - created, - edited, - deleted - from - issue_comments - %s - `, whereClause) - - rows, err := e.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - for rows.Next() { - var comment models.IssueComment - var created string - var rkey, edited, deleted, replyTo sql.Null[string] - err := rows.Scan( - &comment.Id, - &comment.Did, - &rkey, - &comment.IssueAt, - &replyTo, - &comment.Body, - &created, - &edited, - &deleted, - ) - if err != nil { - return nil, err - } - - // this is a remnant from old times, newer comments always have rkey - if rkey.Valid { - comment.Rkey = rkey.V - } - - if t, err := time.Parse(time.RFC3339, created); err == nil { - comment.Created = t - } - - if edited.Valid { - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { - comment.Edited = &t - } - } - - if deleted.Valid { - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { - comment.Deleted = &t - } - } - - if replyTo.Valid { - comment.ReplyTo = &replyTo.V - } - - atUri := comment.AtUri().String() - commentMap[atUri] = &comment - } - - if err = rows.Err(); err != nil { - return nil, err - } - - // collect references for each comments - commentAts := slices.Collect(maps.Keys(commentMap)) - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) - if err != nil { - return nil, fmt.Errorf("failed to query reference_links: %w", err) - } - for commentAt, references := range allReferencs { - if comment, ok := commentMap[commentAt.String()]; ok { - comment.References = references - } - } - - var comments []models.IssueComment - for _, c := range commentMap { - comments = append(comments, *c) - } - - sort.Slice(comments, func(i, j int) bool { - return comments[i].Created.After(comments[j].Created) - }) - - return comments, nil -} - func DeleteIssues(tx *sql.Tx, did, rkey string) error { _, err := tx.Exec( `delete from issues diff --git a/appview/db/reference.go b/appview/db/reference.go index b48d6dfd..15c5353e 100644 --- a/appview/db/reference.go +++ b/appview/db/reference.go @@ -11,7 +11,7 @@ import ( "tangled.org/core/orm" ) -// ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. +// ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. // It will ignore missing refLinks. func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { var ( @@ -53,8 +53,7 @@ func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.AT values %s ) select - i.did, i.rkey, - c.did, c.rkey + i.at_uri, c.at_uri from input inp join repos r on r.did = inp.owner_did @@ -62,9 +61,9 @@ func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.AT join issues i on i.repo_at = r.at_uri and i.issue_id = inp.issue_id - left join issue_comments c + left join comments c on inp.comment_id is not null - and c.issue_at = i.at_uri + and c.subject_at = i.at_uri and c.id = inp.comment_id `, strings.Join(vals, ","), @@ -79,26 +78,16 @@ func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.AT for rows.Next() { // Scan rows - var issueOwner, issueRkey string - var commentOwner, commentRkey sql.NullString + var issueUri string + var commentUri sql.NullString var uri syntax.ATURI - if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { + if err := rows.Scan(&issueUri, &commentUri); err != nil { return nil, err } - if commentOwner.Valid && commentRkey.Valid { - uri = syntax.ATURI(fmt.Sprintf( - "at://%s/%s/%s", - commentOwner.String, - tangled.RepoIssueCommentNSID, - commentRkey.String, - )) + if commentUri.Valid { + uri = syntax.ATURI(commentUri.String) } else { - uri = syntax.ATURI(fmt.Sprintf( - "at://%s/%s/%s", - issueOwner, - tangled.RepoIssueNSID, - issueRkey, - )) + uri = syntax.ATURI(issueUri) } uris = append(uris, uri) } @@ -282,7 +271,7 @@ func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, er return nil, fmt.Errorf("get issue backlinks: %w", err) } backlinks = append(backlinks, ls...) - ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) if err != nil { return nil, fmt.Errorf("get issue_comment backlinks: %w", err) } @@ -351,9 +340,9 @@ func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichRef rows, err := e.Query( fmt.Sprintf( `select r.did, r.name, i.issue_id, c.id, i.title, i.open - from issue_comments c + from comments c join issues i - on i.at_uri = c.issue_at + on i.at_uri = c.subject_at join repos r on r.at_uri = i.repo_at where %s`, diff --git a/appview/ingester.go b/appview/ingester.go index f8c44dde..77931006 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -891,7 +891,7 @@ func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { } switch e.Commit.Operation { - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + case jmodels.CommitOperationUpdate: raw := json.RawMessage(e.Commit.Record) record := tangled.RepoIssueComment{} err = json.Unmarshal(raw, &record) @@ -899,12 +899,20 @@ func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { return fmt.Errorf("invalid record: %w", err) } - comment, err := models.IssueCommentFromRecord(did, rkey, record) + // convert 'sh.tangled.repo.issue.comment' to 'sh.tangled.comment' + comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), tangled.Comment{ + Body: record.Body, + CreatedAt: record.CreatedAt, + Mentions: record.Mentions, + References: record.References, + ReplyTo: record.ReplyTo, + Subject: record.Issue, + }) if err != nil { return fmt.Errorf("failed to parse comment from record: %w", err) } - if err := i.Validator.ValidateIssueComment(comment); err != nil { + if err := comment.Validate(); err != nil { return fmt.Errorf("failed to validate comment: %w", err) } @@ -914,17 +922,18 @@ func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { } defer tx.Rollback() - _, err = db.AddIssueComment(tx, *comment) + err = db.PutComment(tx, comment) if err != nil { - return fmt.Errorf("failed to create issue comment: %w", err) + return fmt.Errorf("failed to create comment: %w", err) } return tx.Commit() case jmodels.CommitOperationDelete: - if err := db.DeleteIssueComments( + if err := db.DeleteComments( ddb, orm.FilterEq("did", did), + orm.FilterEq("collection", e.Commit.Collection), orm.FilterEq("rkey", rkey), ); err != nil { return fmt.Errorf("failed to delete issue comment record: %w", err) diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 9d35bb71..4b077163 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -402,34 +402,39 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { body := r.FormValue("body") if body == "" { - rp.pages.Notice(w, "issue", "Body is required") + rp.pages.Notice(w, "issue-comment", "Body is required") return } - replyToUri := r.FormValue("reply-to") - var replyTo *string - if replyToUri != "" { - replyTo = &replyToUri + var replyTo *syntax.ATURI + replyToRaw := r.FormValue("reply-to") + if replyToRaw != "" { + aturi, err := syntax.ParseATURI(replyToRaw) + if err != nil { + rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI") + return + } + replyTo = &aturi } mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) - comment := models.IssueComment{ - Did: user.Active.Did, + comment := models.Comment{ + Did: syntax.DID(user.Active.Did), + Collection: tangled.CommentNSID, Rkey: tid.TID(), - IssueAt: issue.AtUri().String(), + Subject: issue.AtUri(), ReplyTo: replyTo, Body: body, Created: time.Now(), Mentions: mentions, References: references, } - if err = rp.validator.ValidateIssueComment(&comment); err != nil { + if err = comment.Validate(); err != nil { l.Error("failed to validate comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } - record := comment.AsRecord() client, err := rp.oauth.AuthorizedClient(r) if err != nil { @@ -440,11 +445,11 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { // create a record first resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: comment.Did, + Collection: comment.Collection.String(), + Repo: comment.Did.String(), Rkey: comment.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &record, + Val: comment.AsRecord(), }, }) if err != nil { @@ -467,7 +472,7 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback() - commentId, err := db.AddIssueComment(tx, comment) + err = db.PutComment(tx, &comment) if err != nil { l.Error("failed to create comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") @@ -483,13 +488,10 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { // reset atUri to make rollback a no-op atUri = "" - // notify about the new comment - comment.Id = commentId - rp.notifier.NewIssueComment(r.Context(), &comment, mentions) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) } func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { @@ -504,7 +506,7 @@ func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -540,7 +542,7 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -556,7 +558,7 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { } comment := comments[0] - if comment.Did != user.Active.Did { + if comment.Did.String() != user.Active.Did { l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) return @@ -586,8 +588,6 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { newComment.Edited = &now newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) - record := newComment.AsRecord() - tx, err := rp.db.Begin() if err != nil { l.Error("failed to start transaction", "err", err) @@ -596,7 +596,7 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback() - _, err = db.AddIssueComment(tx, newComment) + err = db.PutComment(tx, &newComment) if err != nil { l.Error("failed to perferom update-description query", "err", err) rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") @@ -606,21 +606,23 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { // rkey is optional, it was introduced later if newComment.Rkey != "" { + // TODO: update correct comment + // update the record on pds - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Active.Did, comment.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", newComment.Collection.String(), newComment.Did.String(), newComment.Rkey) if err != nil { l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") + rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update comment, no record found on PDS.") return } _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: user.Active.Did, + Collection: newComment.Collection.String(), + Repo: newComment.Did.String(), Rkey: newComment.Rkey, SwapRecord: ex.Cid, Record: &lexutil.LexiconTypeDecoder{ - Val: &record, + Val: newComment.AsRecord(), }, }) if err != nil { @@ -650,7 +652,7 @@ func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Re } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -686,7 +688,7 @@ func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -722,7 +724,7 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -738,7 +740,7 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { } comment := comments[0] - if comment.Did != user.Active.Did { + if comment.Did.String() != user.Active.Did { l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) return @@ -751,7 +753,7 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { // optimistic deletion deleted := time.Now() - err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) + err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) if err != nil { l.Error("failed to delete comment", "err", err) rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") @@ -767,8 +769,8 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { return } _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: user.Active.Did, + Collection: comment.Collection.String(), + Repo: comment.Did.String(), Rkey: comment.Rkey, }) if err != nil { diff --git a/appview/models/issue.go b/appview/models/issue.go index 1576d43b..1f9a183d 100644 --- a/appview/models/issue.go +++ b/appview/models/issue.go @@ -26,7 +26,7 @@ type Issue struct { // optionally, populate this when querying for reverse mappings // like comment counts, parent repo etc. - Comments []IssueComment + Comments []Comment Labels LabelState Repo *Repo } @@ -62,8 +62,8 @@ func (i *Issue) State() string { } type CommentListItem struct { - Self *IssueComment - Replies []*IssueComment + Self *Comment + Replies []*Comment } func (it *CommentListItem) Participants() []syntax.DID { @@ -88,13 +88,13 @@ func (it *CommentListItem) Participants() []syntax.DID { func (i *Issue) CommentList() []CommentListItem { // Create a map to quickly find comments by their aturi - toplevel := make(map[string]*CommentListItem) - var replies []*IssueComment + toplevel := make(map[syntax.ATURI]*CommentListItem) + var replies []*Comment // collect top level comments into the map for _, comment := range i.Comments { if comment.IsTopLevel() { - toplevel[comment.AtUri().String()] = &CommentListItem{ + toplevel[comment.AtUri()] = &CommentListItem{ Self: &comment, } } else { @@ -115,7 +115,7 @@ func (i *Issue) CommentList() []CommentListItem { } // sort everything - sortFunc := func(a, b *IssueComment) bool { + sortFunc := func(a, b *Comment) bool { return a.Created.Before(b.Created) } sort.Slice(listing, func(i, j int) bool { @@ -144,7 +144,7 @@ func (i *Issue) Participants() []string { addParticipant(i.Did) for _, c := range i.Comments { - addParticipant(c.Did) + addParticipant(c.Did.String()) } return participants @@ -171,84 +171,3 @@ func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { Open: true, // new issues are open by default } } - -type IssueComment struct { - Id int64 - Did string - Rkey string - IssueAt string - ReplyTo *string - Body string - Created time.Time - Edited *time.Time - Deleted *time.Time - Mentions []syntax.DID - References []syntax.ATURI -} - -func (i *IssueComment) AtUri() syntax.ATURI { - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) -} - -func (i *IssueComment) AsRecord() tangled.RepoIssueComment { - mentions := make([]string, len(i.Mentions)) - for i, did := range i.Mentions { - mentions[i] = string(did) - } - references := make([]string, len(i.References)) - for i, uri := range i.References { - references[i] = string(uri) - } - return tangled.RepoIssueComment{ - Body: i.Body, - Issue: i.IssueAt, - CreatedAt: i.Created.Format(time.RFC3339), - ReplyTo: i.ReplyTo, - Mentions: mentions, - References: references, - } -} - -func (i *IssueComment) IsTopLevel() bool { - return i.ReplyTo == nil -} - -func (i *IssueComment) IsReply() bool { - return i.ReplyTo != nil -} - -func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { - created, err := time.Parse(time.RFC3339, record.CreatedAt) - if err != nil { - created = time.Now() - } - - ownerDid := did - - if _, err = syntax.ParseATURI(record.Issue); err != nil { - return nil, err - } - - i := record - mentions := make([]syntax.DID, len(record.Mentions)) - for i, did := range record.Mentions { - mentions[i] = syntax.DID(did) - } - references := make([]syntax.ATURI, len(record.References)) - for i, uri := range i.References { - references[i] = syntax.ATURI(uri) - } - - comment := IssueComment{ - Did: ownerDid, - Rkey: rkey, - Body: record.Body, - IssueAt: record.Issue, - ReplyTo: record.ReplyTo, - Created: created, - Mentions: mentions, - References: references, - } - - return &comment, nil -} diff --git a/appview/notify/db/db.go b/appview/notify/db/db.go index 047ac77e..ac0c0ac5 100644 --- a/appview/notify/db/db.go +++ b/appview/notify/db/db.go @@ -122,14 +122,14 @@ func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, me ) } -func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) +func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { + issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.Subject)) if err != nil { log.Printf("NewIssueComment: failed to get issues: %v", err) return } if len(issues) == 0 { - log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) + log.Printf("NewIssueComment: no issue found for %s", comment.Subject) return } issue := issues[0] @@ -147,7 +147,7 @@ func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models. // find the parent thread, and add all DIDs from here to the recipient list for _, t := range issue.CommentList() { - if t.Self.AtUri().String() == parentAtUri { + if t.Self.AtUri() == parentAtUri { for _, p := range t.Participants() { recipients.Insert(p) } diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go index bb99b342..a4446cd8 100644 --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -57,7 +57,7 @@ func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, ment m.fanout("NewIssue", ctx, issue, mentions) } -func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { +func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { m.fanout("NewIssueComment", ctx, comment, mentions) } diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go index 45e7e11c..685b71c7 100644 --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -14,7 +14,7 @@ type Notifier interface { DeleteStar(ctx context.Context, star *models.Star) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) + NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) DeleteIssue(ctx context.Context, issue *models.Issue) @@ -43,7 +43,7 @@ func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} -func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { +func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { } func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} diff --git a/appview/notify/posthog/notifier.go b/appview/notify/posthog/notifier.go index 8b2accf3..aa11523b 100644 --- a/appview/notify/posthog/notifier.go +++ b/appview/notify/posthog/notifier.go @@ -179,12 +179,12 @@ func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) } } -func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { +func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { err := n.client.Enqueue(posthog.Capture{ - DistinctId: comment.Did, + DistinctId: comment.Did.String(), Event: "new_issue_comment", Properties: posthog.Properties{ - "issue_at": comment.IssueAt, + "issue_at": comment.Subject, "mentions": mentions, }, }) diff --git a/appview/pages/pages.go b/appview/pages/pages.go index d8c9f475..0c2e3429 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1004,7 +1004,7 @@ type EditIssueCommentParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { @@ -1015,7 +1015,7 @@ type ReplyIssueCommentPlaceholderParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { @@ -1026,7 +1026,7 @@ type ReplyIssueCommentParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { @@ -1037,7 +1037,7 @@ type IssueCommentBodyParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { diff --git a/appview/pages/templates/repo/issues/fragments/commentList.html b/appview/pages/templates/repo/issues/fragments/commentList.html index 765c397c..4fd6cd91 100644 --- a/appview/pages/templates/repo/issues/fragments/commentList.html +++ b/appview/pages/templates/repo/issues/fragments/commentList.html @@ -41,7 +41,7 @@ {{ define "topLevelComment" }}
- {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }}
{{ template "repo/issues/fragments/issueCommentHeader" . }} @@ -53,7 +53,7 @@ {{ define "replyComment" }}
- {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }}
{{ template "repo/issues/fragments/issueCommentHeader" . }} diff --git a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html b/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html index 1c05aaee..fcac063e 100644 --- a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html +++ b/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html @@ -1,11 +1,11 @@ {{ define "repo/issues/fragments/issueCommentHeader" }}
- {{ $handle := resolve .Comment.Did }} + {{ $handle := resolve .Comment.Did.String }} {{ $handle }} {{ template "hats" $ }} {{ template "timestamp" . }} - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} {{ if and $isCommentOwner (not .Comment.Deleted) }} {{ template "editIssueComment" . }} {{ template "deleteIssueComment" . }} diff --git a/appview/validator/issue.go b/appview/validator/issue.go index b199f513..9d0edcbd 100644 --- a/appview/validator/issue.go +++ b/appview/validator/issue.go @@ -4,36 +4,9 @@ import ( "fmt" "strings" - "tangled.org/core/appview/db" "tangled.org/core/appview/models" - "tangled.org/core/orm" ) -func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { - // if comments have parents, only ingest ones that are 1 level deep - if comment.ReplyTo != nil { - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) - if err != nil { - return fmt.Errorf("failed to fetch parent comment: %w", err) - } - if len(parents) != 1 { - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) - } - - // depth check - parent := parents[0] - if parent.ReplyTo != nil { - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") - } - } - - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { - return fmt.Errorf("body is empty after HTML sanitization") - } - - return nil -} - func (v *Validator) ValidateIssue(issue *models.Issue) error { if issue.Title == "" { return fmt.Errorf("issue title is empty") -- 2.51.2 From 77b11ed9ee31f60acbbbf328f6b4082a7c77e94a Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Mon, 8 Dec 2025 23:34:14 +0900 Subject: [PATCH 8/8] appview/notify: merge new comment events into one Signed-off-by: Seongmin Lee --- appview/issues/issues.go | 2 +- appview/notify/db/db.go | 228 ++++++++++++++--------------- appview/notify/merged_notifier.go | 16 +- appview/notify/notifier.go | 14 +- appview/notify/posthog/notifier.go | 22 +-- appview/pulls/pulls.go | 2 +- 6 files changed, 131 insertions(+), 153 deletions(-) diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 4b077163..11da8a6b 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -488,7 +488,7 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { // reset atUri to make rollback a no-op atUri = "" - rp.notifier.NewIssueComment(r.Context(), &comment, mentions) + rp.notifier.NewComment(r.Context(), &comment) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) diff --git a/appview/notify/db/db.go b/appview/notify/db/db.go index ac0c0ac5..3fd90cce 100644 --- a/appview/notify/db/db.go +++ b/appview/notify/db/db.go @@ -74,36 +74,109 @@ func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { // no-op } -func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { - collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) +func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comment) { + var ( + // built the recipients list: + // - the owner of the repo + // - | if the comment is a reply -> everybody on that thread + // | if the comment is a top level -> just the issue owner + // - remove mentioned users from the recipients list + recipients = sets.New[syntax.DID]() + entityType string + entityId string + repoId *int64 + issueId *int64 + pullId *int64 + ) + + subjectDid, err := comment.Subject.Authority().AsDID() if err != nil { - log.Printf("failed to fetch collaborators: %v", err) + log.Printf("NewComment: expected did based at-uri for comment.subject") return } + switch comment.Subject.Collection() { + case tangled.RepoIssueNSID: + issues, err := db.GetIssues( + n.db, + orm.FilterEq("did", subjectDid), + orm.FilterEq("rkey", comment.Subject.RecordKey()), + ) + if err != nil { + log.Printf("NewComment: failed to get issues: %v", err) + return + } + if len(issues) == 0 { + log.Printf("NewComment: no issue found for %s", comment.Subject) + return + } + issue := issues[0] + + recipients.Insert(syntax.DID(issue.Repo.Did)) + if comment.IsReply() { + // if this comment is a reply, then notify everybody in that thread + parentAtUri := *comment.ReplyTo + + // find the parent thread, and add all DIDs from here to the recipient list + for _, t := range issue.CommentList() { + if t.Self.AtUri() == parentAtUri { + for _, p := range t.Participants() { + recipients.Insert(p) + } + } + } + } else { + // not a reply, notify just the issue author + recipients.Insert(syntax.DID(issue.Did)) + } - // build the recipients list - // - owner of the repo - // - collaborators in the repo - // - remove users already mentioned - recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) - for _, c := range collaborators { - recipients.Insert(c.SubjectDid) + entityType = "issue" + entityId = issue.AtUri().String() + repoId = &issue.Repo.Id + issueId = &issue.Id + case tangled.RepoPullNSID: + pulls, err := db.GetPulls( + n.db, + orm.FilterEq("owner_did", subjectDid), + orm.FilterEq("rkey", comment.Subject.RecordKey()), + ) + if err != nil { + log.Printf("NewComment: failed to get pulls: %v", err) + return + } + if len(pulls) == 0 { + log.Printf("NewComment: no pull found for %s", comment.Subject) + return + } + pull := pulls[0] + + pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) + if err != nil { + log.Printf("NewComment: failed to get repos: %v", err) + return + } + + recipients.Insert(syntax.DID(pull.Repo.Did)) + for _, p := range pull.Participants() { + recipients.Insert(syntax.DID(p)) + } + + entityType = "pull" + entityId = pull.AtUri().String() + repoId = &pull.Repo.Id + p := int64(pull.ID) + pullId = &p + default: + return // no-op } - for _, m := range mentions { + + for _, m := range comment.Mentions { recipients.Remove(m) } - actorDid := syntax.DID(issue.Did) - entityType := "issue" - entityId := issue.AtUri().String() - repoId := &issue.Repo.Id - issueId := &issue.Id - var pullId *int64 - n.notifyEvent( - actorDid, + comment.Did, recipients, - models.NotificationTypeIssueCreated, + models.NotificationTypeIssueCommented, entityType, entityId, repoId, @@ -111,8 +184,8 @@ func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, me pullId, ) n.notifyEvent( - actorDid, - sets.Collect(slices.Values(mentions)), + comment.Did, + sets.Collect(slices.Values(comment.Mentions)), models.NotificationTypeUserMentioned, entityType, entityId, @@ -122,47 +195,30 @@ func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, me ) } -func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.Subject)) +func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { + // no-op +} + +func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) if err != nil { - log.Printf("NewIssueComment: failed to get issues: %v", err) - return - } - if len(issues) == 0 { - log.Printf("NewIssueComment: no issue found for %s", comment.Subject) + log.Printf("failed to fetch collaborators: %v", err) return } - issue := issues[0] - // built the recipients list: - // - the owner of the repo - // - | if the comment is a reply -> everybody on that thread - // | if the comment is a top level -> just the issue owner - // - remove mentioned users from the recipients list + // build the recipients list + // - owner of the repo + // - collaborators in the repo + // - remove users already mentioned recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) - - if comment.IsReply() { - // if this comment is a reply, then notify everybody in that thread - parentAtUri := *comment.ReplyTo - - // find the parent thread, and add all DIDs from here to the recipient list - for _, t := range issue.CommentList() { - if t.Self.AtUri() == parentAtUri { - for _, p := range t.Participants() { - recipients.Insert(p) - } - } - } - } else { - // not a reply, notify just the issue author - recipients.Insert(syntax.DID(issue.Did)) + for _, c := range collaborators { + recipients.Insert(c.SubjectDid) } - for _, m := range mentions { recipients.Remove(m) } - actorDid := syntax.DID(comment.Did) + actorDid := syntax.DID(issue.Did) entityType := "issue" entityId := issue.AtUri().String() repoId := &issue.Repo.Id @@ -172,7 +228,7 @@ func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models. n.notifyEvent( actorDid, recipients, - models.NotificationTypeIssueCommented, + models.NotificationTypeIssueCreated, entityType, entityId, repoId, @@ -260,70 +316,6 @@ func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { ) } -func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - pulls, err := db.GetPulls(n.db, - orm.FilterEq("owner_did", comment.Subject.Authority()), - orm.FilterEq("rkey", comment.Subject.RecordKey()), - ) - if err != nil { - log.Printf("NewPullComment: failed to get pulls: %v", err) - return - } - if len(pulls) == 0 { - log.Printf("NewPullComment: no pull found for %s", comment.Subject) - return - } - pull := pulls[0] - - repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) - if err != nil { - log.Printf("NewPullComment: failed to get repos: %v", err) - return - } - - // build up the recipients list: - // - repo owner - // - all pull participants - // - remove those already mentioned - recipients := sets.Singleton(syntax.DID(repo.Did)) - for _, p := range pull.Participants() { - recipients.Insert(syntax.DID(p)) - } - for _, m := range mentions { - recipients.Remove(m) - } - - actorDid := comment.Did - eventType := models.NotificationTypePullCommented - entityType := "pull" - entityId := pull.AtUri().String() - repoId := &repo.Id - var issueId *int64 - p := int64(pull.ID) - pullId := &p - - n.notifyEvent( - actorDid, - recipients, - eventType, - entityType, - entityId, - repoId, - issueId, - pullId, - ) - n.notifyEvent( - actorDid, - sets.Collect(slices.Values(mentions)), - models.NotificationTypeUserMentioned, - entityType, - entityId, - repoId, - issueId, - pullId, - ) -} - func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { // no-op } diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go index a4446cd8..22332685 100644 --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -53,12 +53,16 @@ func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { m.fanout("DeleteStar", ctx, star) } -func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { - m.fanout("NewIssue", ctx, issue, mentions) +func (m *mergedNotifier) NewComment(ctx context.Context, comment *models.Comment) { + m.fanout("NewComment", ctx, comment) +} + +func (m *mergedNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { + m.fanout("DeleteComment", ctx, comment) } -func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - m.fanout("NewIssueComment", ctx, comment, mentions) +func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { + m.fanout("NewIssue", ctx, issue, mentions) } func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { @@ -81,10 +85,6 @@ func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { m.fanout("NewPull", ctx, pull) } -func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - m.fanout("NewPullComment", ctx, comment, mentions) -} - func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { m.fanout("NewPullState", ctx, actor, pull) } diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go index 685b71c7..a8aac9e4 100644 --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -13,8 +13,10 @@ type Notifier interface { NewStar(ctx context.Context, star *models.Star) DeleteStar(ctx context.Context, star *models.Star) + NewComment(ctx context.Context, comment *models.Comment) + DeleteComment(ctx context.Context, comment *models.Comment) + NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) - NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) DeleteIssue(ctx context.Context, issue *models.Issue) @@ -22,7 +24,6 @@ type Notifier interface { DeleteFollow(ctx context.Context, follow *models.Follow) NewPull(ctx context.Context, pull *models.Pull) - NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) UpdateProfile(ctx context.Context, profile *models.Profile) @@ -42,18 +43,17 @@ func (m *BaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {} func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} +func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {} +func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {} + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} -func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { -} func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} -func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} -func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.Comment, mentions []syntax.DID) { -} +func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} diff --git a/appview/notify/posthog/notifier.go b/appview/notify/posthog/notifier.go index aa11523b..0562fcd1 100644 --- a/appview/notify/posthog/notifier.go +++ b/appview/notify/posthog/notifier.go @@ -86,20 +86,6 @@ func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { } } -func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - err := n.client.Enqueue(posthog.Capture{ - DistinctId: comment.Did.String(), - Event: "new_pull_comment", - Properties: posthog.Properties{ - "pull_at": comment.Subject, - "mentions": mentions, - }, - }) - if err != nil { - log.Println("failed to enqueue posthog event:", err) - } -} - func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { err := n.client.Enqueue(posthog.Capture{ DistinctId: pull.OwnerDid, @@ -179,13 +165,13 @@ func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) } } -func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { +func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment) { err := n.client.Enqueue(posthog.Capture{ DistinctId: comment.Did.String(), - Event: "new_issue_comment", + Event: "new_comment", Properties: posthog.Properties{ - "issue_at": comment.Subject, - "mentions": mentions, + "subject_at": comment.Subject, + "mentions": comment.Mentions, }, }) if err != nil { diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index ada50cb4..90c0a7d8 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -781,7 +781,7 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { return } - s.notifier.NewPullComment(r.Context(), &comment, mentions) + s.notifier.NewComment(r.Context(), &comment) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id))