Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Fix some issues with draft patch reviews
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago

This patch adds a New Review button into the patch section, that allows users to create a new draft patch review for a specific revision on a specific patch. Once the user created a review, he shouldn’t be able to create a new review on the same patch and revision.

Once the review which is usually in a draft state (not synced with other nodes) is ready for publishing, the user should click on the publish button available for reviews in draft state. Which would remove the draft object and update the patch to have the new patch revision review.

In case that a patch has older reviews by the same issue, we need to make sure that we only show draft reviews for the correct revision.

checkcheck-unit-testcheck-e2e

👉 Workflow runs 👉 Branch on GitHub

12 files changed +170 -312 622585af 0338e96d
modified crates/radicle-tauri/src/commands/cob.rs
@@ -12,7 +12,6 @@ use tauri_plugin_dialog::DialogExt;

use crate::AppState;

-
pub mod draft;
pub mod issue;
pub mod patch;

deleted crates/radicle-tauri/src/commands/cob/draft.rs
@@ -1,22 +0,0 @@
-
use radicle::cob;
-
use radicle::git;
-
use radicle::identity;
-

-
use radicle_types::error::Error;
-
use radicle_types::traits::cobs::Cobs;
-

-
use crate::AppState;
-

-
/// Puts a draft of a Collaborative Object (COB) out of the draft state by updating the reference to the new object ID (OID).
-
///
-
/// The function updates the reference for the provided `type_name` (e.g., patch, issue) to point to the
-
/// new object ID (OID) associated with the finalized draft, removing it from the draft store.
-
#[tauri::command]
-
pub fn publish_draft(
-
    ctx: tauri::State<AppState>,
-
    rid: identity::RepoId,
-
    cob_id: git::Oid,
-
    type_name: cob::TypeName,
-
) -> Result<(), Error> {
-
    ctx.publish_draft(rid, cob_id, type_name)
-
}
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -1,7 +1,5 @@
-
use radicle::cob;
use radicle::git;
use radicle::identity;
-
use radicle::patch;
use radicle::patch::{Action, TYPENAME};

use radicle_types as types;
@@ -82,56 +80,6 @@ pub fn revision_by_patch_and_id(
}

#[tauri::command]
-
pub fn create_draft_review(
-
    ctx: tauri::State<AppState>,
-
    rid: identity::RepoId,
-
    revision_id: cob::patch::RevisionId,
-
    cob_id: git::Oid,
-
    labels: Vec<cob::Label>,
-
) -> Result<patch::ReviewId, Error> {
-
    ctx.create_draft_review(rid, revision_id, cob_id, labels)
-
}
-

-
/// Creates a new review comment on a draft review for a specific patch.
-
///
-
/// This Tauri command is used to add a comment to an existing draft review in a repository.
-
/// It allows users to comment on a specific location in the code or leave general feedback
-
/// on a review that belongs to a specific patch.
-
#[tauri::command]
-
pub fn create_draft_review_comment(
-
    ctx: tauri::State<AppState>,
-
    rid: identity::RepoId,
-
    cob_id: git::Oid,
-
    new: types::cobs::thread::CreateReviewComment,
-
) -> Result<(), Error> {
-
    ctx.create_draft_review_comment(rid, cob_id, new)
-
}
-

-
/// Edits a draft review for a specific patch revision in a repository.
-
///
-
/// This Tauri command allows users to edit a draft review for a specific patch review.
-
/// The draft is associated with the user (signer) and the provided patch revision within the repository.
-
#[tauri::command]
-
pub fn edit_draft_review(
-
    ctx: tauri::State<AppState>,
-
    rid: identity::RepoId,
-
    cob_id: git::Oid,
-
    edit: models::patch::ReviewEdit,
-
) -> Result<(), Error> {
-
    ctx.edit_draft_review(rid, cob_id, edit)
-
}
-

-
#[tauri::command]
-
pub fn get_draft_review(
-
    ctx: tauri::State<AppState>,
-
    rid: identity::RepoId,
-
    cob_id: git::Oid,
-
    revision_id: patch::RevisionId,
-
) -> Option<models::patch::Review> {
-
    ctx.get_draft_review(rid, cob_id, revision_id)
-
}
-

-
#[tauri::command]
pub fn edit_patch(
    ctx: tauri::State<AppState>,
    rid: identity::RepoId,
modified crates/radicle-tauri/src/lib.rs
@@ -110,11 +110,6 @@ pub fn run() {
            cob::patch::edit_patch,
            cob::patch::revisions_by_patch,
            cob::patch::revision_by_patch_and_id,
-
            cob::patch::create_draft_review,
-
            cob::patch::create_draft_review_comment,
-
            cob::patch::get_draft_review,
-
            cob::patch::edit_draft_review,
-
            cob::draft::publish_draft,
            thread::create_issue_comment,
            thread::create_patch_comment,
            profile::config,
modified crates/radicle-types/src/cobs.rs
@@ -7,7 +7,6 @@ use radicle::node::{Alias, AliasStore};

pub mod diff;
pub mod issue;
-
pub mod patch;
pub mod stream;
pub mod thread;

deleted crates/radicle-types/src/cobs/patch.rs
@@ -1 +0,0 @@
-

modified crates/radicle-types/src/traits/cobs.rs
@@ -1,6 +1,4 @@
-
use radicle::cob::object::Storage;
-
use radicle::storage::refs::draft;
-
use radicle::storage::{self, ReadStorage};
+
use radicle::storage::ReadStorage;
use radicle::{cob, git, identity};
use serde::de::DeserializeOwned;

@@ -39,37 +37,4 @@ pub trait Cobs: Profile {

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

-
    fn publish_draft(
-
        &self,
-
        rid: identity::RepoId,
-
        cob_id: git::Oid,
-
        type_name: cob::TypeName,
-
    ) -> Result<(), Error> {
-
        let profile = self.profile();
-
        let signer = profile.signer()?;
-
        let repo = profile.storage.repository(rid)?;
-
        let draft_oid = repo.backend.refname_to_id(&draft::cob(
-
            signer.public_key(),
-
            &type_name,
-
            &cob_id.into(),
-
        ))?;
-
        repo.update(
-
            signer.public_key(),
-
            &type_name,
-
            &cob_id.into(),
-
            &draft_oid.into(),
-
        )?;
-

-
        let mut patches = profile.patches_mut(&repo)?;
-
        patches.write(&cob_id.into())?;
-

-
        storage::git::cob::DraftStore::new(&repo, *signer.public_key()).remove(
-
            signer.public_key(),
-
            &type_name,
-
            &cob_id.into(),
-
        )?;
-

-
        Ok::<_, Error>(())
-
    }
}
modified crates/radicle-types/src/traits/patch.rs
@@ -1,8 +1,7 @@
use radicle::node::Handle;
use radicle::patch::cache::Patches as _;
-
use radicle::storage;
use radicle::storage::ReadStorage;
-
use radicle::{cob, git, identity, patch, Node};
+
use radicle::{git, identity, Node};

use crate::cobs;
use crate::domain::patch::models;
@@ -253,138 +252,4 @@ pub trait PatchesMut: Profile {

        Ok::<_, Error>(models::patch::Patch::new(*patch.id(), &patch, &aliases))
    }
-

-
    /// Gets the draft review of the local user for a specific patch revision in a repository.
-
    ///
-
    /// This Tauri command is used to retrieve a patch review draft for the local user
-
    /// on a given patch revision from a repository.
-
    /// It looks up the repository using the provided repository ID (`rid`) and patch object ID (`cob_id`),
-
    /// and gets the patch review of the local user associated with a specific revision (`revision_id`), if it exists.
-
    fn get_draft_review(
-
        &self,
-
        rid: identity::RepoId,
-
        cob_id: git::Oid,
-
        revision_id: patch::RevisionId,
-
    ) -> Option<models::patch::Review> {
-
        let profile = self.profile();
-
        let aliases = profile.aliases();
-
        let repo = profile.storage.repository(rid).ok()?;
-
        let signer = profile.signer().ok()?;
-
        let drafts = storage::git::cob::DraftStore::new(&repo, *signer.public_key());
-
        let patches = cob::patch::Cache::no_cache(&drafts).ok()?;
-

-
        let patch = patches.get(&cob_id.into()).ok()?;
-
        let revision = patch.and_then(|p| p.revision(&revision_id).cloned());
-
        let review: Option<patch::Review> =
-
            revision.and_then(|rev| rev.review_by(signer.public_key()).cloned());
-

-
        review.map(|r| models::patch::Review::new(r, &aliases))
-
    }
-

-
    /// Edits a draft review for a specific patch revision in a repository.
-
    ///
-
    /// This Tauri command allows users to edit a draft review for a specific patch review.
-
    /// The draft is associated with the user (signer) and the provided patch revision within the repository.
-
    fn edit_draft_review(
-
        &self,
-
        rid: identity::RepoId,
-
        cob_id: git::Oid,
-
        edit: models::patch::ReviewEdit,
-
    ) -> Result<(), Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let signer = profile.signer()?;
-
        let drafts = storage::git::cob::DraftStore::new(&repo, *signer.public_key());
-

-
        let mut patches = cob::patch::Cache::no_cache(&drafts)?;
-
        let mut patch = patches.get_mut(&cob_id.into())?;
-
        patch.review_edit(
-
            edit.review_id,
-
            edit.verdict.map(|v| v.into()),
-
            edit.summary,
-
            edit.labels,
-
            &signer,
-
        )?;
-

-
        patches.write(&cob_id.into())?;
-

-
        Ok::<_, Error>(())
-
    }
-

-
    /// Creates a draft review for a specific patch revision.
-
    ///
-
    /// This Tauri command allows users to create a new draft review for a specific patch revision.
-
    /// The draft is associated with the user (signer) and the provided patch revision within the repository.
-
    fn create_draft_review(
-
        &self,
-
        rid: identity::RepoId,
-
        revision_id: cob::patch::RevisionId,
-
        cob_id: git::Oid,
-
        labels: Vec<cob::Label>,
-
    ) -> Result<patch::ReviewId, Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let signer = profile.signer()?;
-
        let drafts = storage::git::cob::DraftStore::new(&repo, *signer.public_key());
-

-
        let mut patches = cob::patch::Cache::no_cache(&drafts)?;
-
        let mut patch = patches.get_mut(&cob_id.into())?;
-
        let revision = patch
-
            .revision(&revision_id)
-
            .ok_or_else(|| Error::WithHint {
-
                err: anyhow::anyhow!("patch revision not found"),
-
                hint: "Not able to find the specified patch revision.",
-
            })?;
-

-
        let review_id = match revision.review_by(signer.public_key()) {
-
            Some(review) => review.id(),
-
            None => patch.review(
-
                revision.id(),
-
                Some(cob::patch::Verdict::Reject),
-
                None,
-
                labels,
-
                &signer,
-
            )?,
-
        };
-

-
        patches.write(&cob_id.into())?;
-

-
        Ok::<_, Error>(review_id)
-
    }
-

-
    /// Creates a new review comment on a draft review for a specific patch.
-
    ///
-
    /// This Tauri command is used to add a comment to an existing draft review in a repository.
-
    /// It allows users to comment on a specific location in the code or leave general feedback
-
    /// on a review that belongs to a specific patch.
-
    fn create_draft_review_comment(
-
        &self,
-
        rid: identity::RepoId,
-
        cob_id: git::Oid,
-
        new: cobs::thread::CreateReviewComment,
-
    ) -> Result<(), Error> {
-
        let profile = self.profile();
-
        let repo = profile.storage.repository(rid)?;
-
        let signer = profile.signer()?;
-
        let drafts = storage::git::cob::DraftStore::new(&repo, *signer.public_key());
-

-
        let mut patches = cob::patch::Cache::no_cache(&drafts)?;
-
        let mut patch = patches.get_mut(&cob_id.into())?;
-

-
        patch.transaction("Review comments", &signer, |tx| {
-
            tx.review_comment(
-
                new.review_id,
-
                new.body,
-
                new.location.map(|l| l.into()),
-
                new.reply_to,
-
                new.embeds.into_iter().map(Into::into).collect::<Vec<_>>(),
-
            )?;
-

-
            Ok(())
-
        })?;
-

-
        patches.write(&cob_id.into())?;
-

-
        Ok::<_, Error>(())
-
    }
}
modified src/components/Icon.svelte
@@ -861,8 +861,12 @@
    <path d="M3 12H4V13H3V12Z" />
    <path d="M3 13H4V14H3V13Z" />
  {:else if name === "plus"}
-
    <path d="M7.00002 2H9.00002V14H7.00002V2Z" />
-
    <path d="M14 7V9L2.00002 9L2.00002 7L14 7Z" />
+
    <path d="M2 3H3V13H2V3Z" />
+
    <path d="M3 13H13V14H3L3 13Z" />
+
    <path d="M3 2H13V3L3 3L3 2Z" />
+
    <path d="M13 3L14 3V13H13V3Z" />
+
    <path d="M7 5H9V11H7V5Z" />
+
    <path d="M5 7H11V9H5V7Z" />
  {:else if name === "reply"}
    <path d="M2.5 9V8H3.5V9H2.5Z" />
    <path d="M3.5 10L3.5 9L4.5 9V10L3.5 10Z" />
modified src/components/NakedButton.svelte
@@ -4,6 +4,7 @@
  interface Props {
    children: Snippet;
    title?: string;
+
    disabled?: boolean;
    variant: "primary" | "secondary" | "ghost";
    onclick?: (e: MouseEvent) => void;
    styleHeight?: string;
@@ -13,6 +14,7 @@
  const {
    children,
    title,
+
    disabled,
    variant,
    onclick,
    styleHeight = "2rem",
@@ -116,78 +118,83 @@
    grid-area: p5-5;
  }

-
  .container:hover .p1-3 {
+
  .container:hover:not(.disabled) .p1-3 {
    background-color: var(--button-color-1);
  }

-
  .container:hover .p2-2 {
+
  .container:hover:not(.disabled) .p2-2 {
    background-color: var(--button-color-1);
  }
-
  .container:hover .p2-4 {
+
  .container:hover:not(.disabled) .p2-4 {
    background-color: var(--button-color-1);
  }

-
  .container:hover .p3-1 {
+
  .container:hover:not(.disabled) .p3-1 {
    background-color: var(--button-color-1);
  }
-
  .container:hover .p3-5 {
+
  .container:hover:not(.disabled) .p3-5 {
    background-color: var(--button-color-1);
  }

-
  .container:hover .p4-2 {
+
  .container:hover:not(.disabled) .p4-2 {
    background-color: var(--button-color-1);
  }
-
  .container:hover .p4-4 {
+
  .container:hover:not(.disabled) .p4-4 {
    background-color: var(--button-color-1);
  }

-
  .container:hover .p5-3 {
+
  .container:hover:not(.disabled) .p5-3 {
    background-color: var(--button-color-1);
  }

-
  .container:active .p1-3 {
+
  .container:active:not(.disabled) .p1-3 {
    background-color: var(--button-color-1);
  }

-
  .container:active .p2-2 {
+
  .container:active:not(.disabled) .p2-2 {
    background-color: var(--button-color-1);
  }
-
  .container:active .p2-3 {
+
  .container:active:not(.disabled) .p2-3 {
    background-color: var(--button-color-3);
  }
-
  .container:active .p2-4 {
+
  .container:active:not(.disabled) .p2-4 {
    background-color: var(--button-color-1);
  }

-
  .container:active .p3-1 {
+
  .container:active:not(.disabled) .p3-1 {
    background-color: var(--button-color-1);
  }
-
  .container:active .p3-2 {
+
  .container:active:not(.disabled) .p3-2 {
    background-color: var(--button-color-3);
  }
-
  .container:active .p3-3 {
+
  .container:active:not(.disabled) .p3-3 {
    background-color: var(--button-color-1);
  }
-
  .container:active .p3-4 {
+
  .container:active:not(.disabled) .p3-4 {
    background-color: var(--button-color-2);
  }
-
  .container:active .p3-5 {
+
  .container:active:not(.disabled) .p3-5 {
    background-color: var(--button-color-1);
  }

-
  .container:active .p4-2 {
+
  .container:active:not(.disabled) .p4-2 {
    background-color: var(--button-color-1);
  }
-
  .container:active .p4-3 {
+
  .container:active:not(.disabled) .p4-3 {
    background-color: var(--button-color-2);
  }
-
  .container:active .p4-4 {
+
  .container:active:not(.disabled) .p4-4 {
    background-color: var(--button-color-1);
  }
-
  .container:active .p5-3 {
+
  .container:active:not(.disabled) .p5-3 {
    background-color: var(--button-color-1);
  }

+
  .container.disabled {
+
    color: var(--color-foreground-disabled);
+
    cursor: inherit;
+
  }
+

  .container {
    cursor: pointer;
    white-space: nowrap;
@@ -213,7 +220,8 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
  class="container"
-
  {onclick}
+
  class:disabled
+
  onclick={!disabled ? onclick : undefined}
  {title}
  role="button"
  tabindex="0"
modified src/components/ReviewTeaser.svelte
@@ -18,44 +18,89 @@

  const { rid, review }: Props = $props();

-
  const backgroundColor = {
-
    review: "var(--color-background-float)",
-
    accept: "var(--color-fill-diff-green-light)",
-
    reject: "var(--color-fill-diff-red-light)",
-
  };
+
  const backgroundColor = $derived.by(() => {
+
    if (review.verdict === "accept") {
+
      return "var(--color-fill-diff-green-light)";
+
    } else if (review.verdict === "reject") {
+
      return "var(--color-fill-diff-red-light)";
+
    } else {
+
      return "var(--color-fill-float-hover)";
+
    }
+
  });
+

+
  const header = $derived.by(() => {
+
    if (!review.verdict) {
+
      return "published a review";
+
    }
+

+
    return `${review.verdict ? `${review.verdict}ed` : "reviewed"} revision with a review`;
+
  });
+

+
  const icon = $derived.by(() => {
+
    if (review.verdict === "accept") {
+
      return "comment-checkmark";
+
    } else if (review.verdict === "reject") {
+
      return "comment-cross";
+
    } else {
+
      return "comment";
+
    }
+
  });
</script>

<style>
  .review {
    clip-path: var(--2px-corner-fill);
+
    display: flex;
+
    align-items: flex-start;
    padding: 0.5rem 0.75rem;
+
    gap: 1rem;
+
  }
+
  .review-content {
+
    width: 100%;
    font-size: var(--font-size-small);
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
  }
+
  .timestamp {
+
    color: var(--color-foreground-dim);
+
  }
+
  .review-header {
+
    display: flex;
+
    justify-content: space-between;
+
    align-items: center;
+
    width: 100%;
+
  }
+
  .icon {
+
    padding-top: 0.25rem;
+
  }
</style>

-
<div
-
  class="review"
-
  style:background-color={backgroundColor[review.verdict ?? "review"]}>
-
  <div class="global-flex">
-
    <NodeId {...authorForNodeId(review.author)} />
-
    {review.verdict ? `${review.verdict}ed` : "reviewed"} revision
-
    <div title={absoluteTimestamp(review.timestamp)}>
-
      {formatTimestamp(review.timestamp)}
-
    </div>
-
    {#if review.comments.length > 0}
-
      <div class="global-flex" style:gap="0.25rem" style:margin-left="auto">
-
        <Icon name="comment" />{review.comments.length}
-
      </div>
-
    {/if}
+
<div class="review" style:background-color={backgroundColor}>
+
  <div class="icon">
+
    <Icon name={icon} />
  </div>
-
  <div>
-
    {#if review.summary?.trim()}
-
      <Markdown {rid} breaks content={review.summary} />
-
    {:else}
-
      <span class="txt-missing">No summary.</span>
-
    {/if}
+
  <div class="review-content">
+
    <div class="review-header">
+
      <div class="global-flex">
+
        <NodeId {...authorForNodeId(review.author)} />
+
        <span>{header}</span>
+
        <div class="timestamp" title={absoluteTimestamp(review.timestamp)}>
+
          {formatTimestamp(review.timestamp)}
+
        </div>
+
        {#if review.comments.length > 0}
+
          <div class="global-flex" style:gap="0.25rem" style:margin-left="auto">
+
            <Icon name="comment" />{review.comments.length}
+
          </div>
+
        {/if}
+
      </div>
+
    </div>
+
    <div>
+
      {#if review.summary?.trim()}
+
        <Markdown {rid} breaks content={review.summary} />
+
      {:else}
+
        <span class="txt-missing">No summary.</span>
+
      {/if}
+
    </div>
  </div>
</div>
modified src/components/Revision.svelte
@@ -14,7 +14,11 @@
  import { announce } from "@app/components/AnnounceSwitch.svelte";
  import { invoke } from "@app/lib/invoke";
  import { nodeRunning } from "@app/lib/events";
-
  import { publicKeyFromDid, scrollIntoView } from "@app/lib/utils";
+
  import {
+
    didFromPublicKey,
+
    publicKeyFromDid,
+
    scrollIntoView,
+
  } from "@app/lib/utils";

  import Changeset from "@app/components/Changeset.svelte";
  import CobCommitTeaser from "./CobCommitTeaser.svelte";
@@ -41,6 +45,15 @@
    $props();
  /* eslint-enable prefer-const */

+
  const hasOwnReview = $derived(
+
    Boolean(
+
      revision.reviews &&
+
        revision.reviews.some(
+
          value => value.author.did === didFromPublicKey(config.publicKey),
+
        ),
+
    ),
+
  );
+

  let focusReply: boolean = $state(false);
  let hideChanges = $state(false);
  let hideDiscussion = $state(false);
@@ -128,6 +141,27 @@
    }
  }

+
  async function createReview() {
+
    try {
+
      await invoke("edit_patch", {
+
        rid: rid,
+
        cobId: patchId,
+
        action: {
+
          type: "review",
+
          revision: revision.id,
+
          // We need to pass an empty string to create a review without a verdict.
+
          summary: "",
+
          labels: [],
+
        },
+
        opts: { announce: $nodeRunning && $announce },
+
      });
+
    } catch (error) {
+
      console.error("Creating a review failed: ", error);
+
    } finally {
+
      await reload();
+
    }
+
  }
+

  async function editComment(commentId: string, body: string, embeds: Embed[]) {
    try {
      await invoke("edit_patch", {
@@ -143,7 +177,7 @@
        opts: { announce: $nodeRunning && $announce },
      });
    } catch (error) {
-
      console.error("Eediting comment failed: ", error);
+
      console.error("Editing comment failed: ", error);
    } finally {
      await reload();
    }
@@ -293,6 +327,11 @@
    left: -18.5px;
    background-color: var(--color-fill-separator);
  }
+
  .review-row {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
  }
</style>

<div class="txt-small patch-body">
@@ -360,24 +399,38 @@
  </div>
</div>

-
{#if revision.reviews && revision.reviews.length}
-
  <div style:margin="1rem 0">
-
    <div class="global-flex" style:margin-bottom="1rem">
+
<div style:margin="1rem 0">
+
  <div class="review-row">
+
    <div
+
      class="global-flex"
+
      style:margin-bottom={hideReviews || revision.reviews?.length === 0
+
        ? undefined
+
        : "1rem"}>
      <NakedButton
        stylePadding="0 4px"
        variant="ghost"
        onclick={() => (hideReviews = !hideReviews)}>
-
        <Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
+
        <Icon
+
          name={hideReviews || revision.reviews?.length === 0
+
            ? "chevron-right"
+
            : "chevron-down"} />
        <div class="txt-semibold global-flex txt-regular">Reviews</div>
      </NakedButton>
    </div>
-
    <div class:hide={hideReviews} style:margin-top="1rem">
+
    <NakedButton variant="ghost" disabled={hasOwnReview} onclick={createReview}>
+
      <Icon name="plus" />
+
      <span class="txt-small">New Review</span>
+
    </NakedButton>
+
  </div>
+

+
  {#if revision.reviews && revision.reviews.length}
+
    <div class:hide={hideReviews}>
      {#each revision.reviews as review}
        <ReviewTeaser {rid} {review} />
      {/each}
    </div>
-
  </div>
-
{/if}
+
  {/if}
+
</div>

<div
  class="txt-semibold global-flex"