Make Operation generic, and handle patch operations

So far we only supported issue operations on the `activity_by_id`
command.

With this commit we separate the commands into a `activity_by_issue` and
`activity_by_patch` command that calls a generic `activity_by_id` trait
implementation.

`Operation` now takes as a generic either `cob/issue/Action.ts` or
`cob/patch/Action.ts`.

Also `Operation` now stores the array of all `Action` that happened on
said operation. I'm not entirely sure if this will help us in the
future, but it represents more closely the state of the relation between
an `Operation` and the cob `Actions`.
This commit is contained in:
Sebastian Martinez
2025-01-10 10:33:32 -06:00
committed by Rūdolfs Ošiņš
parent 63d1c87c7f
commit 432db1ce5b
16 changed files with 218 additions and 134 deletions
-12
View File
@@ -2,12 +2,10 @@ use std::path::PathBuf;
use anyhow::{Context, Result};
use radicle::cob;
use radicle::git;
use radicle::identity;
use radicle_types as types;
use radicle_types::error::Error;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::thread::Thread;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::DialogExt;
@@ -84,13 +82,3 @@ pub async fn save_embed_to_disk(
ctx.save_embed_to_disk(rid, oid, path)
}
#[tauri::command]
pub fn activity_by_id(
ctx: tauri::State<AppState>,
rid: identity::RepoId,
type_name: cob::TypeName,
id: git::Oid,
) -> Result<Vec<types::cobs::issue::Operation>, Error> {
ctx.activity_by_id(rid, type_name, id)
}
@@ -1,8 +1,10 @@
use radicle::git;
use radicle::identity;
use radicle::issue::{Action, TYPENAME};
use radicle_types as types;
use radicle_types::error::Error;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::issue::Issues;
use radicle_types::traits::issue::IssuesMut;
@@ -55,3 +57,12 @@ pub(crate) fn comment_threads_by_issue_id(
) -> Result<Option<Vec<types::cobs::thread::Thread>>, Error> {
ctx.comment_threads_by_issue_id(rid, id)
}
#[tauri::command]
pub fn activity_by_issue(
ctx: tauri::State<AppState>,
rid: identity::RepoId,
id: git::Oid,
) -> Result<Vec<types::cobs::Operation<Action>>, Error> {
ctx.activity_by_id(rid, &TYPENAME, id)
}
@@ -3,8 +3,10 @@ use radicle::git;
use radicle::identity;
use radicle::patch;
use radicle::patch::{Action, TYPENAME};
use radicle_types as types;
use radicle_types::error::Error;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::patch::Patches;
use radicle_types::traits::patch::PatchesMut;
@@ -109,3 +111,12 @@ pub fn edit_patch(
) -> Result<types::cobs::patch::Patch, Error> {
ctx.edit_patch(rid, cob_id, action, opts)
}
#[tauri::command]
pub fn activity_by_patch(
ctx: tauri::State<AppState>,
rid: identity::RepoId,
id: git::Oid,
) -> Result<Vec<types::cobs::Operation<Action>>, Error> {
ctx.activity_by_id(rid, &TYPENAME, id)
}
+2 -1
View File
@@ -78,15 +78,16 @@ pub fn run() {
diff::get_diff,
cob::get_embed,
cob::save_embed_to_disk,
cob::activity_by_id,
cob::save_embed_by_path,
cob::save_embed_by_clipboard,
cob::save_embed_by_bytes,
cob::issue::activity_by_issue,
cob::issue::list_issues,
cob::issue::issue_by_id,
cob::issue::comment_threads_by_issue_id,
cob::issue::create_issue,
cob::issue::edit_issue,
cob::patch::activity_by_patch,
cob::patch::list_patches,
cob::patch::patch_by_id,
cob::patch::edit_patch,
@@ -0,0 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Author } from "./Author";
/**
* Everything that can be done in the system is represented by an `Op`.
* Operations are applied to an accumulator to yield a final state.
*/
export type Operation<A> = {
id: string;
actions: Array<A>;
author: Author;
timestamp: number;
};
@@ -1,27 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Author } from "../Author";
import type { Embed } from "../thread/Embed";
import type { State } from "./State";
export type Operation =
& { entryId: string; timestamp: number; author: Author }
& (
| { "type": "assign"; assignees: Array<string> }
| { "type": "edit"; title: string }
| { "type": "lifecycle"; state: State }
| { "type": "label"; labels: Array<string> }
| {
"type": "comment";
body: string;
replyTo?: string;
embeds?: Array<Embed>;
}
| {
"type": "comment.edit";
id: string;
body: string;
embeds?: Array<Embed>;
}
| { "type": "comment.redact"; id: string }
| { "type": "comment.react"; id: string; reaction: string; active: boolean }
);
+16
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use radicle::cob;
use radicle::identity;
use radicle::node::{Alias, AliasStore};
@@ -31,6 +32,21 @@ impl Author {
}
}
/// Everything that can be done in the system is represented by an `Op`.
/// Operations are applied to an accumulator to yield a final state.
#[derive(Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/")]
pub struct Operation<A> {
#[ts(as = "String")]
pub id: cob::EntryId,
pub actions: Vec<A>,
pub author: Author,
#[ts(type = "number")]
pub timestamp: cob::Timestamp,
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
-15
View File
@@ -1,6 +1,5 @@
use std::collections::BTreeSet;
use radicle::git;
use radicle::node::AliasStore;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -55,20 +54,6 @@ impl Issue {
}
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/issue/")]
pub struct Operation {
#[ts(as = "String")]
pub entry_id: git::Oid,
#[serde(flatten)]
pub action: Action,
#[ts(type = "number")]
pub timestamp: cob::Timestamp,
pub author: cobs::Author,
}
#[derive(Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", tag = "status")]
#[ts(export)]
+4
View File
@@ -9,6 +9,10 @@ pub enum Error {
#[error(transparent)]
Profile(#[from] radicle::profile::Error),
/// CobStore error.
#[error(transparent)]
CobStore(#[from] radicle::cob::store::Error),
/// Anyhow error.
#[error(transparent)]
Anyhow(#[from] anyhow::Error),
+23 -20
View File
@@ -2,39 +2,42 @@ use radicle::cob::object::Storage;
use radicle::storage::refs::draft;
use radicle::storage::{self, ReadStorage};
use radicle::{cob, git, identity};
use serde::de::DeserializeOwned;
use crate::error::Error;
use crate::traits::Profile;
pub trait Cobs: Profile {
fn activity_by_id(
#[allow(clippy::unnecessary_filter_map)]
fn activity_by_id<A: DeserializeOwned>(
&self,
rid: identity::RepoId,
type_name: cob::TypeName,
type_name: &cob::TypeName,
id: git::Oid,
) -> Result<Vec<crate::cobs::issue::Operation>, Error> {
) -> Result<Vec<crate::cobs::Operation<A>>, Error> {
let profile = self.profile();
let aliases = profile.aliases();
let repo = profile.storage.repository(rid)?;
let ops = cob::store::ops(&id.into(), &type_name, &repo).unwrap();
let mut actions: Vec<crate::cobs::issue::Operation> = Vec::new();
let iter = cob::store::ops(&id.into(), type_name, &repo)?;
let ops = iter
.into_iter()
.filter_map(|op| {
let actions = op
.actions
.iter()
.filter_map(|a| serde_json::from_slice(a).ok())
.collect::<Vec<_>>();
for op in ops.into_iter() {
actions.extend(op.actions.iter().filter_map(
|action: &Vec<u8>| -> Option<crate::cobs::issue::Operation> {
let action: crate::cobs::issue::Action = serde_json::from_slice(action).ok()?;
Some(crate::cobs::Operation {
id: op.id,
actions,
author: crate::cobs::Author::new(&op.author.into(), &aliases),
timestamp: op.timestamp,
})
})
.collect::<Vec<_>>();
Some(crate::cobs::issue::Operation {
entry_id: op.id,
action,
author: crate::cobs::Author::new(&op.author.into(), &aliases),
timestamp: op.timestamp,
})
},
))
}
Ok::<_, Error>(actions)
Ok::<_, Error>(ops)
}
fn publish_draft(
+24 -8
View File
@@ -8,13 +8,14 @@ use axum::routing::post;
use axum::Router;
use hyper::header::CONTENT_TYPE;
use hyper::Method;
use radicle::cob::TypeName;
use serde::{Deserialize, Serialize};
use tower_http::cors::{self, CorsLayer};
use radicle::{git, identity};
use radicle_types as types;
use radicle_types::cobs::issue::{Action, NewIssue};
use radicle_types::cobs::issue;
use radicle_types::cobs::issue::NewIssue;
use radicle_types::cobs::patch;
use radicle_types::cobs::CobOptions;
use radicle_types::error::Error;
use radicle_types::traits::auth::Auth;
@@ -57,7 +58,14 @@ pub fn router(ctx: Context) -> Router {
.route("/list_repos", post(repo_root_handler))
.route("/repo_by_id", post(repo_handler))
.route("/diff_stats", post(diff_stats_handler))
.route("/activity_by_id", post(activity_handler))
.route(
"/activity_by_issue",
post(activity_issue_handler::<issue::Action>),
)
.route(
"/activity_by_patch",
post(activity_patch_handler::<patch::Action>),
)
.route("/get_diff", post(diff_handler))
.route("/list_issues", post(issues_handler))
.route("/create_issue", post(create_issue_handler))
@@ -206,7 +214,7 @@ async fn create_issue_comment_handler(
struct EditIssuesBody {
pub rid: identity::RepoId,
pub cob_id: git::Oid,
pub action: Action,
pub action: issue::Action,
pub opts: CobOptions,
}
@@ -228,15 +236,23 @@ async fn edit_issue_handler(
#[serde(rename_all = "camelCase")]
struct ActivityBody {
pub rid: identity::RepoId,
pub type_name: TypeName,
pub id: git::Oid,
}
async fn activity_handler(
async fn activity_issue_handler<A: serde::Serialize + serde::de::DeserializeOwned>(
State(ctx): State<Context>,
Json(ActivityBody { rid, type_name, id }): Json<ActivityBody>,
Json(ActivityBody { rid, id }): Json<ActivityBody>,
) -> impl IntoResponse {
let activity = ctx.activity_by_id(rid, type_name, id)?;
let activity = ctx.activity_by_id::<A>(rid, &radicle::cob::issue::TYPENAME, id)?;
Ok::<_, Error>(Json(activity))
}
async fn activity_patch_handler<A: serde::Serialize + serde::de::DeserializeOwned>(
State(ctx): State<Context>,
Json(ActivityBody { rid, id }): Json<ActivityBody>,
) -> impl IntoResponse {
let activity = ctx.activity_by_id::<A>(rid, &radicle::cob::patch::TYPENAME, id)?;
Ok::<_, Error>(Json(activity))
}
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Operation } from "@bindings/cob/issue/Operation";
import type { Operation } from "@bindings/cob/Operation";
import type { Action } from "@bindings/cob/issue/Action";
import { authorForNodeId, formatTimestamp } from "@app/lib/utils";
@@ -8,19 +9,20 @@
import NodeId from "@app/components/NodeId.svelte";
interface Props {
operation: Extract<Operation, { type: "lifecycle" }>;
action: Extract<Action, { type: "lifecycle" }>;
op: Operation<Action>;
}
const { operation }: Props = $props();
const { op, action }: Props = $props();
</script>
<Border variant="float" stylePadding="1rem">
<div class="txt-small">
<div class="global-flex txt-small">
<NodeId {...authorForNodeId(operation.author)} />
<NodeId {...authorForNodeId(op.author)} />
changed status to
<IssueStateBadge state={operation.state} />
{formatTimestamp(operation.timestamp)}
<IssueStateBadge state={action.state} />
{formatTimestamp(op.timestamp)}
</div>
</div>
</Border>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { Operation } from "@bindings/cob/Operation";
import type { Action } from "@bindings/cob/patch/Action";
import { authorForNodeId, formatTimestamp } from "@app/lib/utils";
import Border from "@app/components/Border.svelte";
import NodeId from "@app/components/NodeId.svelte";
import PatchStateBadge from "./PatchStateBadge.svelte";
interface Props {
action: Extract<Action, { type: "lifecycle" }>;
op: Operation<Action>;
}
const { op, action }: Props = $props();
</script>
<Border variant="float" stylePadding="1rem">
<div class="txt-small">
<div class="global-flex txt-small">
<NodeId {...authorForNodeId(op.author)} />
changed status to
<PatchStateBadge state={action.state} />
{formatTimestamp(op.timestamp)}
</div>
</div>
</Border>
+24 -22
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import type { Action } from "@bindings/cob/issue/Action";
import type { Author } from "@bindings/cob/Author";
import type { Config } from "@bindings/config/Config";
import type { Embed } from "@bindings/cob/thread/Embed";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Operation } from "@bindings/cob/issue/Operation";
import type { Operation } from "@bindings/cob/Operation";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Thread } from "@bindings/cob/thread/Thread";
import type { IssueStatus } from "./router";
@@ -45,7 +46,7 @@
repo: RepoInfo;
issue: Issue;
issues: Issue[];
activity: Operation[];
activity: Operation<Action>[];
config: Config;
threads: Thread[];
status: IssueStatus;
@@ -149,9 +150,8 @@
rid: repo.rid,
id: issue.id,
}),
invoke<Operation[]>("activity_by_id", {
invoke<Operation<Action>[]>("activity_by_issue", {
rid: repo.rid,
typeName: "xyz.radicle.issue",
id: issue.id,
}),
invoke<Thread[]>("comment_threads_by_issue_id", {
@@ -524,26 +524,28 @@
<div>
{#each activity as op}
{#if op.type === "lifecycle"}
<IssueTimelineLifecycleAction operation={op} />
<div class="connector"></div>
{:else if op.type === "comment"}
{@const thread = threads.find(t => t.root.id === op.entryId)}
{#if thread}
<ThreadComponent
{thread}
rid={repo.rid}
canEditComment={partial(
roles.isDelegateOrAuthor,
config.publicKey,
repo.delegates.map(delegate => delegate.did),
)}
{editComment}
createReply={partial(createReply)}
reactOnComment={partial(reactOnComment, config.publicKey)} />
{#each op.actions as action}
{#if action.type === "lifecycle"}
<IssueTimelineLifecycleAction {op} {action} />
<div class="connector"></div>
{:else if action.type === "comment"}
{@const thread = threads.find(t => t.root.id === op.id)}
{#if thread}
<ThreadComponent
{thread}
rid={repo.rid}
canEditComment={partial(
roles.isDelegateOrAuthor,
config.publicKey,
repo.delegates.map(delegate => delegate.did),
)}
{editComment}
createReply={partial(createReply)}
reactOnComment={partial(reactOnComment, config.publicKey)} />
<div class="connector"></div>
{/if}
{/if}
{/if}
{/each}
{/each}
</div>
+15
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import type { Operation } from "@bindings/cob/Operation";
import type { Action } from "@bindings/cob/patch/Action";
import type { Author } from "@bindings/cob/Author";
import type { Config } from "@bindings/config/Config";
import type { Diff } from "@bindings/diff/Diff";
@@ -45,6 +47,7 @@
patches: PaginatedQuery<Patch[]>;
revisions: Revision[];
config: Config;
activity: Operation<Action>[];
status: PatchStatus | undefined;
}
@@ -56,6 +59,7 @@
revisions,
config,
status: initialStatus,
activity,
}: Props = $props();
/* eslint-enable prefer-const */
@@ -535,6 +539,17 @@
) && partial(editRevision, revisions[0].id)}>
</CommentComponent>
</div>
<div class="connector"></div>
<div>
{#each activity as op}
{#each op.actions as action}
{#if action.type === "revision"}
<div>New revision created {action.oid}</div>
<div class="connector"></div>
{/if}
{/each}
{/each}
</div>
{:else}
{@const revision = revisions.slice(-1)[0]}
{#await loadHighlightedDiff(repo.rid, revision.base, revision.head) then diff}
+39 -23
View File
@@ -1,7 +1,9 @@
import type { Action as IssueAction } from "@bindings/cob/issue/Action";
import type { Action as PatchAction } from "@bindings/cob/patch/Action";
import type { Config } from "@bindings/config/Config";
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Operation } from "@bindings/cob/issue/Operation";
import type { Operation } from "@bindings/cob/Operation";
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
@@ -33,7 +35,7 @@ export interface LoadedRepoIssueRoute {
issue: Issue;
issues: Issue[];
status: IssueStatus;
activity: Operation[];
activity: Operation<IssueAction>[];
threads: Thread[];
};
}
@@ -82,6 +84,7 @@ export interface LoadedRepoPatchRoute {
patches: PaginatedQuery<Patch[]>;
status: PatchStatus | undefined;
revisions: Revision[];
activity: Operation<PatchAction>[];
};
}
@@ -117,28 +120,42 @@ export type LoadedRepoRoute =
export async function loadPatch(
route: RepoPatchRoute,
): Promise<LoadedRepoPatchRoute> {
const [config, repo, patches, patch, revisions] = await Promise.all([
invoke<Config>("config"),
invoke<RepoInfo>("repo_by_id", {
rid: route.rid,
}),
invoke<PaginatedQuery<Patch[]>>("list_patches", {
rid: route.rid,
status: route.status,
}),
invoke<Patch>("patch_by_id", {
rid: route.rid,
id: route.patch,
}),
invoke<Revision[]>("revisions_by_patch", {
rid: route.rid,
id: route.patch,
}),
]);
const [config, repo, patches, patch, revisions, activity] = await Promise.all(
[
invoke<Config>("config"),
invoke<RepoInfo>("repo_by_id", {
rid: route.rid,
}),
invoke<PaginatedQuery<Patch[]>>("list_patches", {
rid: route.rid,
status: route.status,
}),
invoke<Patch>("patch_by_id", {
rid: route.rid,
id: route.patch,
}),
invoke<Revision[]>("revisions_by_patch", {
rid: route.rid,
id: route.patch,
}),
invoke<Operation<PatchAction>[]>("activity_by_patch", {
rid: route.rid,
id: route.patch,
}),
],
);
return {
resource: "repo.patch",
params: { repo, config, patch, patches, revisions, status: route.status },
params: {
repo,
config,
patch,
patches,
revisions,
status: route.status,
activity,
},
};
}
@@ -194,9 +211,8 @@ export async function loadIssue(
rid: route.rid,
id: route.issue,
}),
invoke<Operation[]>("activity_by_id", {
invoke<Operation<IssueAction>[]>("activity_by_issue", {
rid: route.rid,
typeName: "xyz.radicle.issue",
id: route.issue,
}),
invoke<Issue[]>("list_issues", {