Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Make `Operation` generic, and handle patch operations
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago

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.

checkcheck-e2e

šŸ‘‰ Workflow runs šŸ‘‰ Branch on GitHub

16 files changed +218 -135 63d1c87c → 432db1ce
modified crates/radicle-tauri/src/commands/cob.rs
@@ -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)
-
}
modified crates/radicle-tauri/src/commands/cob/issue.rs
@@ -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)
+
}
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -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)
+
}
modified crates/radicle-tauri/src/lib.rs
@@ -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,
added crates/radicle-types/bindings/cob/Operation.ts
@@ -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;
+
};
deleted crates/radicle-types/bindings/cob/issue/Operation.ts
@@ -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 }
-
  );
modified crates/radicle-types/src/cobs.rs
@@ -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)]
modified crates/radicle-types/src/cobs/issue.rs
@@ -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)]
modified crates/radicle-types/src/error.rs
@@ -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),
modified crates/radicle-types/src/traits/cobs.rs
@@ -2,39 +2,41 @@ 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(
+
    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();
-

-
        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()?;
+
        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<_>>();

-
                    Some(crate::cobs::issue::Operation {
-
                        entry_id: op.id,
-
                        action,
-
                        author: crate::cobs::Author::new(&op.author.into(), &aliases),
-
                        timestamp: op.timestamp,
-
                    })
-
                },
-
            ))
-
        }
+
                Some(crate::cobs::Operation {
+
                    id: op.id,
+
                    actions,
+
                    author: crate::cobs::Author::new(&op.author.into(), &aliases),
+
                    timestamp: op.timestamp,
+
                })
+
            })
+
            .collect::<Vec<_>>();

-
        Ok::<_, Error>(actions)
+
        Ok::<_, Error>(ops)
    }

    fn publish_draft(
modified crates/test-http-api/src/api.rs
@@ -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, id }): Json<ActivityBody>,
+
) -> impl IntoResponse {
+
    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, 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::patch::TYPENAME, id)?;

    Ok::<_, Error>(Json(activity))
}
modified src/components/IssueTimelineLifecycleAction.svelte
@@ -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>
added src/components/PatchTimelineLifecycleAction.svelte
@@ -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>
modified src/views/repo/Issue.svelte
@@ -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>

modified src/views/repo/Patch.svelte
@@ -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";
@@ -43,11 +45,13 @@
    patches: PaginatedQuery<Patch[]>;
    revisions: Revision[];
    config: Config;
+
    activity: Operation<Action>[];
    status: PatchStatus | undefined;
  }

  /* eslint-disable prefer-const */
-
  let { repo, patch, patches, revisions, config, status }: Props = $props();
+
  let { repo, patch, patches, revisions, config, status, activity }: Props =
+
    $props();
  /* eslint-enable prefer-const */

  let cursor = patches.cursor;
@@ -416,6 +420,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}
modified src/views/repo/router.ts
@@ -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", {