rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood802e5551a3d1f84c51b10f848905c48e38cfacd8
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "816c1c8c587e03889afa631837f89b05a92259f1",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"title": "httpd: Add notification inbox to `/node/inbox` route",
"state": {
"status": "archived",
"conflicts": []
},
"before": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"after": "802e5551a3d1f84c51b10f848905c48e38cfacd8",
"commits": [
"802e5551a3d1f84c51b10f848905c48e38cfacd8",
"9b1f47d1b74178b8278a0feb379f20ccac185327",
"fbe33340edb3e341ae8ed6209e0068c055d1981b"
],
"target": "6cfed884bf37cba1e0d8e97fa8b0e94df4a04b1f",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "816c1c8c587e03889afa631837f89b05a92259f1",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Adds all notifications, sorted by timestamp.\nProvides endpoint to clear the entire inbox, or change the read status\non a single notification.",
"base": "137961c1f72a6850325b05e65a232cd202cbd12c",
"oid": "1e3aa222a60910835a8205501b6811827bf0cf90",
"timestamp": 1707578451
},
{
"id": "7e72b19e89ea728eb3ff0e71c8f132a49b68e7e8",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Add ref updates to branch notifications",
"base": "137961c1f72a6850325b05e65a232cd202cbd12c",
"oid": "188d57033fe9e39e0be2a5db32e3e0ad4db61557",
"timestamp": 1707579938
},
{
"id": "ad225e3b7de8e5688586a891f02df08ebd0f8d10",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Add aliases to remote property",
"base": "137961c1f72a6850325b05e65a232cd202cbd12c",
"oid": "4454422a1ec3e02ad9f06d35e31f9887389d450d",
"timestamp": 1707590824
},
{
"id": "cd24dedb2a9f547697e5846f10dc7ddbad953ea5",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Add project name to repo field",
"base": "137961c1f72a6850325b05e65a232cd202cbd12c",
"oid": "b6f1987871fa82f28a2f702064a971d739359099",
"timestamp": 1707593228
},
{
"id": "c0d8e341addf90cf72ddc6979149f0fadd82cb83",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Add `GET /projects/:rid/inbox` endpoint",
"base": "137961c1f72a6850325b05e65a232cd202cbd12c",
"oid": "0b546f657344920af904e6705d8e36bcee1f295c",
"timestamp": 1707746456
},
{
"id": "5e7450cb342739941305d25ad7bbb61c633f7933",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Add some improvements to the json output",
"base": "137961c1f72a6850325b05e65a232cd202cbd12c",
"oid": "abcb83c93105b3f9659f0520db5a3d12abfc6d49",
"timestamp": 1708361778
},
{
"id": "dcdc4825dc0801469fbc2f194ffa1234cbf246ee",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Add cob title to endpoint, update project inbox endpoint",
"base": "137961c1f72a6850325b05e65a232cd202cbd12c",
"oid": "1d86737247013415830ce1870f38e6a6e31a5dfc",
"timestamp": 1708391899
},
{
"id": "ef965ee6bd61be869f299109a2ec0985b8f5771d",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Add branch name as summary",
"base": "137961c1f72a6850325b05e65a232cd202cbd12c",
"oid": "de9f38c2ee670a6bb45f9e0a3519f90b8791a522",
"timestamp": 1708455521
},
{
"id": "051f90b4f338d0d04ebb98ddbf3c451f0e5881cd",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Add some repo information back",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "784c16ea3fd6e442458941340a2a58623888a956",
"timestamp": 1708526222
},
{
"id": "734d279f4289d60f4efd938b13760d4a65bb2bb7",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Added clear and set read status by repo, also added `success` property and cleared count to delete responses.",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "1814b3074284ef66c1d90b1da42fc3abb53b3df5",
"timestamp": 1708533636
},
{
"id": "82efa43a57cb6a0f82d0f93017ad5e8cc17d0cb2",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Fix clippy warnings",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "2659bf1f7fe66bf32885ef25ef17692892050d6e",
"timestamp": 1708533962
},
{
"id": "d92a5dfd103abfbfacc12b6c4625fd8a5ff9b879",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Rewrite route handler comments",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "fa488e523a9282365aa72652fd07f1eeeb2074e4",
"timestamp": 1708534015
},
{
"id": "03fc47683a7289d5b95b8bfed8b7cbec1ed7ef64",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Remove redundant filter_map",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "6dc31db6b8b750536738ab834f166ef8cac23a0a",
"timestamp": 1708534342
},
{
"id": "58e3123bc59fc1cb34dbcdd7d08ccfb196531ca4",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Improve naming of json output properties",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "802e5551a3d1f84c51b10f848905c48e38cfacd8",
"timestamp": 1708536566
},
{
"id": "2f2277b45814834f63e67c37f6ca7eca37a4d5ae",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"alias": "sebastinez"
},
"description": "Changes:\n- Use types to better encapsulate the data needed for producing the\n JSON output",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "0fd3ecb8e53972c6eb349f27d149555b2353352f",
"timestamp": 1709554627
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "062b1810-b7a1-41eb-8f2e-82ec2fafbcaf"
},
"info_url": "https://cci.rad.levitte.org//062b1810-b7a1-41eb-8f2e-82ec2fafbcaf.html"
}
Started at: 2025-10-21 18:32:42.628132+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/062b1810-b7a1-41eb-8f2e-82ec2fafbcaf/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 125 issues · 15 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 816c1c8c587e03889afa631837f89b05a92259f1
✓ Switched to branch patch/816c1c8 at revision 58e3123
✓ Branch patch/816c1c8 setup to track rad/patches/816c1c8c587e03889afa631837f89b05a92259f1
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout 802e5551a3d1f84c51b10f848905c48e38cfacd8
HEAD is now at 802e5551 httpd: Add notifications
Exit code: 0
$ git show 802e5551a3d1f84c51b10f848905c48e38cfacd8
commit 802e5551a3d1f84c51b10f848905c48e38cfacd8
Author: Sebastian Martinez <me@sebastinez.dev>
Date: Sat Feb 10 12:19:37 2024 -0300
httpd: Add notifications
Adding the following routes:
- `DELETE /node/inbox` Clear the entire inbox
- `PATCH /node/inbox/:id` Toggle read status of a notification
- `DELETE /node/inbox/:id` Clear a notification by id
- `GET /projects/inbox` Get a listing of repos that have notifications
- `GET /projects/:rid/inbox` Get all notifications by repo
- `PATCH /projects/:rid/inbox` Mark all notifications by repo as read
- `DELETE /projects/:rid/inbox` Clear all notifications by repo
diff --git a/Cargo.lock b/Cargo.lock
index 4411c804..ad3a4fb0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2477,6 +2477,7 @@ dependencies = [
"flate2",
"hyper",
"lexopt",
+ "localtime",
"lru",
"nonempty 0.9.0",
"pretty_assertions",
diff --git a/radicle-httpd/Cargo.toml b/radicle-httpd/Cargo.toml
index 0dba5bf6..b53f5c9a 100644
--- a/radicle-httpd/Cargo.toml
+++ b/radicle-httpd/Cargo.toml
@@ -25,6 +25,7 @@ fastrand = { version = "2.0.0" }
flate2 = { version = "1" }
hyper = { version = "1.0.1", default-features = false }
lexopt = { version = "0.3.0" }
+localtime = { version = "1.2.0" }
lru = { version = "0.12.0" }
nonempty = { version = "0.9.0", features = ["serialize"] }
radicle-surf = { version = "0.18.0", default-features = false, features = ["serde"] }
diff --git a/radicle-httpd/src/api/error.rs b/radicle-httpd/src/api/error.rs
index 90c7b4cd..9882c26d 100644
--- a/radicle-httpd/src/api/error.rs
+++ b/radicle-httpd/src/api/error.rs
@@ -46,6 +46,10 @@ pub enum Error {
#[error(transparent)]
Repository(#[from] radicle::storage::RepositoryError),
+ /// Notification error.
+ #[error(transparent)]
+ Notification(#[from] radicle::node::notifications::Error),
+
/// Routing error.
#[error(transparent)]
Routing(#[from] radicle::node::routing::Error),
diff --git a/radicle-httpd/src/api/json.rs b/radicle-httpd/src/api/json.rs
index 0902cdae..332f5345 100644
--- a/radicle-httpd/src/api/json.rs
+++ b/radicle-httpd/src/api/json.rs
@@ -5,16 +5,17 @@ use std::path::Path;
use std::str;
use base64::prelude::{Engine, BASE64_STANDARD};
-use radicle::cob::{CodeLocation, Reaction};
-use radicle::patch::ReviewId;
use serde_json::{json, Value};
use radicle::cob::issue::{Issue, IssueId};
use radicle::cob::patch::{Merge, Patch, PatchId, Review};
use radicle::cob::thread::{Comment, CommentId, Edit};
use radicle::cob::{ActorId, Author};
+use radicle::cob::{CodeLocation, Label, Reaction};
use radicle::git::RefString;
+use radicle::node::notifications::{Notification, NotificationStatus};
use radicle::node::{Alias, AliasStore};
+use radicle::patch::ReviewId;
use radicle::prelude::NodeId;
use radicle::storage::{git, refs, RemoteRepository};
use radicle_surf::blob::Blob;
@@ -167,6 +168,64 @@ fn author(author: &Author, alias: Option<Alias>) -> Value {
}
}
+/// Returns JSON for a cob notification.
+pub fn notification_cob(
+ n: &Notification,
+ category: String,
+ cob_id: String,
+ title: String,
+ labels: Vec<&Label>,
+ state: String,
+ aliases: &impl AliasStore,
+) -> Value {
+ json!({
+ "id": n.id,
+ "repo": n.repo,
+ "remote": n.remote.map(|a| author(&Author::from(a), aliases.alias(&a))),
+ "category": category,
+ "cob_id": cob_id,
+ "title": title,
+ "labels": labels,
+ "state": state,
+ "status": notification_status(&n.status),
+ "timestamp": n.timestamp.to_string(),
+ })
+}
+
+/// Returns JSON for a branch notification.
+pub fn notification_branch(
+ n: &Notification,
+ category: String,
+ name: String,
+ title: String,
+ state: String,
+ aliases: &impl AliasStore,
+) -> Value {
+ json!({
+ "id": n.id,
+ "repo": n.repo,
+ "remote": n.remote.map(|a| author(&Author::from(a), aliases.alias(&a))),
+ "category": category,
+ "name": name,
+ "title": title,
+ "state": state,
+ "status": notification_status(&n.status),
+ "timestamp": n.timestamp.to_string(),
+ })
+}
+
+/// Returns JSON for a `notification`.
+fn notification_status(status: &NotificationStatus) -> Value {
+ match status {
+ NotificationStatus::ReadAt(time) => {
+ json!({ "type": "readAt", "timestamp": time.to_string() })
+ }
+ NotificationStatus::Unread => {
+ json!({ "type": "unread" })
+ }
+ }
+}
+
/// Returns JSON for a patch `Merge` and fills in `alias` when present.
fn merge(nid: &NodeId, merge: &Merge, aliases: &impl AliasStore) -> Value {
json!({
diff --git a/radicle-httpd/src/api/v1/node.rs b/radicle-httpd/src/api/v1/node.rs
index 0181868d..279ecf86 100644
--- a/radicle-httpd/src/api/v1/node.rs
+++ b/radicle-httpd/src/api/v1/node.rs
@@ -1,12 +1,16 @@
-use axum::extract::State;
+use std::net::SocketAddr;
+
+use axum::extract::{ConnectInfo, State};
use axum::response::IntoResponse;
-use axum::routing::{get, put};
+use axum::routing::{delete, get, patch, put};
use axum::{Json, Router};
use axum_auth::AuthBearer;
use hyper::StatusCode;
+use localtime::LocalTime;
use serde_json::json;
use radicle::identity::RepoId;
+use radicle::node::notifications::{NotificationId, NotificationStatus};
use radicle::node::routing::Store;
use radicle::node::{policy, AliasStore, Handle, NodeId, DEFAULT_TIMEOUT};
use radicle::Node;
@@ -18,6 +22,11 @@ use crate::axum_extra::{Path, Query};
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/node", get(node_handler))
+ .route("/node/inbox", delete(node_inbox_clear_handler))
+ .route(
+ "/node/inbox/:id",
+ patch(node_inbox_item_update_handler).delete(node_inbox_item_clear_handler),
+ )
.route("/node/policies/repos", get(node_policies_repos_handler))
.route(
"/node/policies/repos/:rid",
@@ -55,6 +64,77 @@ async fn node_handler(State(ctx): State<Context>) -> impl IntoResponse {
Ok::<_, Error>(Json(response))
}
+/// Toggle a local node inbox item read status.
+/// `PATCH /node/inbox/:id`
+async fn node_inbox_item_update_handler(
+ State(ctx): State<Context>,
+ AuthBearer(token): AuthBearer,
+ Path(id): Path<NotificationId>,
+ ConnectInfo(addr): ConnectInfo<SocketAddr>,
+) -> impl IntoResponse {
+ if !addr.ip().is_loopback() {
+ return Err(Error::Auth(
+ "Node inbox data updates are only able for localhost",
+ ));
+ }
+ api::auth::validate(&ctx, &token).await?;
+ let mut notifs = ctx.profile.notifications_mut()?;
+ let notification = notifs.get(id)?;
+ let state = match notification.status {
+ NotificationStatus::Unread => {
+ notifs.set_status(NotificationStatus::ReadAt(LocalTime::now()), &[id])?
+ }
+ NotificationStatus::ReadAt(..) => notifs.set_status(NotificationStatus::Unread, &[id])?,
+ };
+
+ Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": state }))))
+}
+
+/// Clear a local node inbox item.
+/// `DELETE /node/inbox/:id`
+async fn node_inbox_item_clear_handler(
+ State(ctx): State<Context>,
+ AuthBearer(token): AuthBearer,
+ Path(id): Path<NotificationId>,
+ ConnectInfo(addr): ConnectInfo<SocketAddr>,
+) -> impl IntoResponse {
+ if !addr.ip().is_loopback() {
+ return Err(Error::Auth(
+ "Node inbox data updates are only able for localhost",
+ ));
+ }
+ api::auth::validate(&ctx, &token).await?;
+ let mut notifs = ctx.profile.notifications_mut()?;
+ let cleared = notifs.clear(&[id])?;
+
+ Ok::<_, Error>((
+ StatusCode::OK,
+ Json(json!({ "success": true, "count": cleared })),
+ ))
+}
+
+/// Clear a local node inbox.
+/// `DELETE /node/inbox`
+async fn node_inbox_clear_handler(
+ State(ctx): State<Context>,
+ AuthBearer(token): AuthBearer,
+ ConnectInfo(addr): ConnectInfo<SocketAddr>,
+) -> impl IntoResponse {
+ if !addr.ip().is_loopback() {
+ return Err(Error::Auth(
+ "Node inbox data updates are only able for localhost",
+ ));
+ }
+ api::auth::validate(&ctx, &token).await?;
+ let mut notifs = ctx.profile.notifications_mut()?;
+ let cleared = notifs.clear_all()?;
+
+ Ok::<_, Error>((
+ StatusCode::OK,
+ Json(json!({ "success": true, "count": cleared })),
+ ))
+}
+
/// Return stored information about other nodes.
/// `GET /nodes/:nid`
async fn nodes_handler(State(ctx): State<Context>, Path(nid): Path<NodeId>) -> impl IntoResponse {
diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs
index 149b0058..2e78fb72 100644
--- a/radicle-httpd/src/api/v1/projects.rs
+++ b/radicle-httpd/src/api/v1/projects.rs
@@ -9,17 +9,21 @@ use axum::routing::{get, patch, post};
use axum::{Json, Router};
use axum_auth::AuthBearer;
use hyper::StatusCode;
+use localtime::LocalTime;
use radicle_surf::blob::BlobRef;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tower_http::set_header::SetResponseHeaderLayer;
-use radicle::cob::{issue, patch, resolve_embed, Embed, Label, Uri};
+use radicle::cob::{self, issue, patch, resolve_embed, Embed, Label, Uri};
use radicle::identity::{Did, RepoId, Visibility};
+use radicle::issue::Issues;
+use radicle::node::notifications::{NotificationKind, NotificationStatus};
use radicle::node::routing::Store;
use radicle::node::{AliasStore, Node, NodeId};
+use radicle::patch::Patches;
use radicle::storage::git::paths;
-use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository, WriteRepository};
+use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, RemoteRepository, WriteRepository};
use radicle_surf::{diff, Glob, Oid, Repository};
use crate::api::error::Error;
@@ -33,10 +37,17 @@ const MAX_BODY_LIMIT: usize = 4_194_304;
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/projects", get(project_root_handler))
+ .route("/projects/inbox", get(project_inbox_handler))
.route("/projects/:project", get(project_handler))
.route("/projects/:project/commits", get(history_handler))
.route("/projects/:project/commits/:sha", get(commit_handler))
.route("/projects/:project/diff/:base/:oid", get(diff_handler))
+ .route(
+ "/projects/:project/inbox",
+ get(inbox_handler)
+ .patch(inbox_toggle_status_handler)
+ .delete(inbox_clear_handler),
+ )
.route(
"/projects/:project/activity",
get(
@@ -152,6 +163,25 @@ async fn project_root_handler(
Ok::<_, Error>(Json(infos))
}
+/// GET projects that have notifications.
+/// `GET /projects/inbox`
+async fn project_inbox_handler(State(ctx): State<Context>) -> impl IntoResponse {
+ let store = ctx.profile.notifications()?;
+ let storage = &ctx.profile.storage;
+ let repos = store
+ .repos_with_notifications()?
+ .collect::<Result<Vec<_>, _>>()?;
+
+ let mut infos = Vec::new();
+ for rid in repos {
+ let repo = storage.repository(rid)?;
+ let project = repo.project()?;
+ infos.push(json!({ "rid": rid, "name": project.name() }));
+ }
+
+ Ok::<_, Error>(Json(infos))
+}
+
/// Get project metadata.
/// `GET /projects/:project`
async fn project_handler(State(ctx): State<Context>, Path(id): Path<RepoId>) -> impl IntoResponse {
@@ -411,6 +441,151 @@ async fn activity_handler(
Ok::<_, Error>((StatusCode::OK, Json(json!({ "activity": timestamps }))))
}
+/// Get notifications by repo.
+/// `GET /projects/:project/inbox`
+async fn inbox_handler(
+ State(ctx): State<Context>,
+ Path(rid): Path<RepoId>,
+ ConnectInfo(addr): ConnectInfo<SocketAddr>,
+) -> impl IntoResponse {
+ if !addr.ip().is_loopback() {
+ return Err(Error::Auth("Node inbox data is only shown for localhost"));
+ }
+ let storage = &ctx.profile.storage;
+ let aliases = &ctx.profile.aliases();
+ let repo = storage.repository(rid)?;
+ let (_, head) = repo.head()?;
+ let issues = Issues::open(&repo)?;
+ let patches = Patches::open(&repo)?;
+ let store = ctx.profile.notifications()?;
+ let notifications: Vec<_> = store
+ .by_repo(&rid, "timestamp")?
+ .filter_map(|notification| {
+ notification.ok().and_then(|n| {
+ match &n.kind {
+ NotificationKind::Cob { id, type_name } => {
+ if type_name == &*cob::issue::TYPENAME {
+ let Some(issue) = issues.get(id).ok().flatten() else {
+ // Issue could have been deleted after notification was created.
+ return None;
+ };
+ Some(api::json::notification_cob(
+ &n,
+ "issue".to_string(),
+ id.to_string(),
+ issue.title().to_owned(),
+ issue.labels().collect(),
+ issue.state().to_string(),
+ aliases,
+ ))
+ } else if type_name == &*cob::patch::TYPENAME {
+ let Some(patch) = patches.get(id).ok().flatten() else {
+ // Patch could have been deleted after notification was created.
+ return None;
+ };
+ Some(api::json::notification_cob(
+ &n,
+ "patch".to_string(),
+ id.to_string(),
+ patch.title().to_owned(),
+ patch.labels().collect(),
+ patch.state().to_string(),
+ aliases,
+ ))
+ } else {
+ None
+ }
+ }
+ NotificationKind::Branch { name } => {
+ let commit = if let Some(head) = n.update.new() {
+ repo.commit(head)
+ .ok()?
+ .summary()
+ .unwrap_or_default()
+ .to_owned()
+ } else {
+ String::new()
+ };
+
+ let state = match n
+ .update
+ .new()
+ .map(|oid| repo.is_ancestor_of(oid, head))
+ .transpose()
+ .ok()
+ .flatten()
+ {
+ Some(true) => "merged",
+ Some(false) | None => match n.update {
+ RefUpdate::Created { .. } => "created",
+ RefUpdate::Deleted { .. } => "deleted",
+ RefUpdate::Skipped { .. } => "skipped",
+ RefUpdate::Updated { .. } => "updated",
+ },
+ }
+ .to_owned();
+
+ Some(api::json::notification_branch(
+ &n,
+ "branch".to_string(),
+ name.to_string(),
+ commit,
+ state,
+ aliases,
+ ))
+ }
+ }
+ })
+ })
+ .collect();
+
+ Ok::<_, Error>(Json(notifications))
+}
+
+/// Mark as read all node inbox items by repo.
+/// `PATCH /projects/:rid/inbox`
+async fn inbox_toggle_status_handler(
+ State(ctx): State<Context>,
+ AuthBearer(token): AuthBearer,
+ Path(rid): Path<RepoId>,
+ ConnectInfo(addr): ConnectInfo<SocketAddr>,
+) -> impl IntoResponse {
+ if !addr.ip().is_loopback() {
+ return Err(Error::Auth(
+ "Node inbox data updates are only able for localhost",
+ ));
+ }
+ api::auth::validate(&ctx, &token).await?;
+ let mut store = ctx.profile.notifications_mut()?;
+ let ids = store
+ .by_repo(&rid, "timestamp")?
+ .filter_map(|n| n.ok().map(|x| x.id))
+ .collect::<Vec<_>>();
+ let success = store.set_status(NotificationStatus::ReadAt(LocalTime::now()), &ids)?;
+
+ Ok::<_, Error>(Json(json!({ "success": success })))
+}
+
+/// Clear a local node inbox by repo.
+/// `DELETE /projects/:rid/inbox`
+async fn inbox_clear_handler(
+ State(ctx): State<Context>,
+ AuthBearer(token): AuthBearer,
+ Path(rid): Path<RepoId>,
+ ConnectInfo(addr): ConnectInfo<SocketAddr>,
+) -> impl IntoResponse {
+ if !addr.ip().is_loopback() {
+ return Err(Error::Auth(
+ "Node inbox data updates are only able for localhost",
+ ));
+ }
+ api::auth::validate(&ctx, &token).await?;
+ let mut store = ctx.profile.notifications_mut()?;
+ let cleared = store.clear_by_repo(&rid)?;
+
+ Ok::<_, Error>(Json(json!({ "success": true, "count": cleared })))
+}
+
/// Get project source tree for '/' path.
/// `GET /projects/:project/tree/:sha/`
async fn tree_handler_root(
diff --git a/radicle/src/node/notifications/store.rs b/radicle/src/node/notifications/store.rs
index 60c368a3..b005b8e7 100644
--- a/radicle/src/node/notifications/store.rs
+++ b/radicle/src/node/notifications/store.rs
@@ -303,6 +303,20 @@ impl<T> Store<T> {
}))
}
+ /// Get repos that have notifications.
+ pub fn repos_with_notifications(
+ &self,
+ ) -> Result<impl Iterator<Item = Result<RepoId, Error>> + '_, Error> {
+ let stmt = self
+ .db
+ .prepare("SELECT DISTINCT repo FROM `repository-notifications`")?;
+
+ Ok(stmt.into_iter().map(move |row| {
+ let row = row?;
+ row.try_read::<RepoId, _>("repo").map_err(Error::from)
+ }))
+ }
+
/// Get the total notification count.
pub fn count(&self) -> Result<usize, Error> {
let stmt = self
Exit code: 0
shell: 'cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny clippy::all cargo build --all-targets --workspace cargo doc --workspace cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 062b1810-b7a1-41eb-8f2e-82ec2fafbcaf -v /opt/radcis/ci.rad.levitte.org/cci/state/062b1810-b7a1-41eb-8f2e-82ec2fafbcaf/s:/062b1810-b7a1-41eb-8f2e-82ec2fafbcaf/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/062b1810-b7a1-41eb-8f2e-82ec2fafbcaf/w:/062b1810-b7a1-41eb-8f2e-82ec2fafbcaf/w -w /062b1810-b7a1-41eb-8f2e-82ec2fafbcaf/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:bookworm bash /062b1810-b7a1-41eb-8f2e-82ec2fafbcaf/s/script.sh
+ cargo --version
info: syncing channel updates for '1.74-x86_64-unknown-linux-gnu'
info: latest update on 2023-12-07, rust version 1.74.1 (a28077b28 2023-12-04)
info: downloading component 'cargo'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: installing component 'cargo'
info: installing component 'rust-std'
info: installing component 'rustc'
cargo 1.74.1 (ecb9851af 2023-10-18)
+ rustc --version
rustc 1.74.1 (a28077b28 2023-12-04)
+ cargo fmt --check
error: 'cargo-fmt' is not installed for the toolchain '1.74-x86_64-unknown-linux-gnu'.
To install, run `rustup component add --toolchain 1.74-x86_64-unknown-linux-gnu rustfmt`
Exit code: 1
{
"response": "finished",
"result": "failure"
}