git_bug/entities/issue/issue_operation/
mod.rs

1// git-bug-rs - A rust library for interfacing with git-bug repositories
2//
3// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
4// SPDX-License-Identifier: GPL-3.0-or-later
5//
6// This file is part of git-bug-rs/git-gub.
7//
8// You should have received a copy of the License along with this program.
9// If not, see <https://www.gnu.org/licenses/agpl.txt>.
10
11//! Operations that affect an [`Issue`][`super::Issue`]
12
13use std::{fmt::Display, str::FromStr};
14
15use gix::ObjectId;
16use operation_type::IssueOperationType;
17use serde::{Deserialize, Serialize};
18use simd_json::{base::ValueAsScalar, derived::ValueTryIntoObject, owned};
19
20use super::{
21    Issue,
22    data::{label::Label, status::Status},
23};
24use crate::replica::entity::{
25    id::{Id, entity_id::EntityId},
26    operation::operation_data::OperationData,
27};
28
29mod op;
30pub mod operation_type;
31
32/// A file that is attached to an Issue or Issue comment.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct File {
35    git_id: Vec<u8>,
36}
37impl TryFrom<&owned::Value> for File {
38    type Error = file_parse::Error;
39
40    fn try_from(value: &owned::Value) -> Result<Self, Self::Error> {
41        let s = value
42            .as_str()
43            .ok_or_else(|| file_parse::Error::ExpectedStr {
44                data: value.to_owned(),
45            })?;
46
47        let object_id = ObjectId::from_str(s)?;
48
49        Ok(Self {
50            git_id: object_id.as_slice().to_owned(),
51        })
52    }
53}
54
55#[allow(missing_docs)]
56pub mod file_parse {
57    use simd_json::owned;
58
59    #[derive(Debug, thiserror::Error)]
60    pub enum Error {
61        #[error("Expected the '{data}' data, to be a string, but was not.")]
62        ExpectedStr { data: owned::Value },
63
64        #[error("Failed to parse the object id from json data for file: {0}")]
65        ObjectIdParse(#[from] gix::hash::decode::Error),
66    }
67}
68
69impl Display for File {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        ObjectId::from_bytes_or_panic(self.git_id.as_slice()).fmt(f)
72    }
73}
74
75/// The specific operations that affect only [`Issues`][`super::Issue`].
76#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
77pub enum IssueOperationData {
78    /// Adding a comment.
79    AddComment {
80        /// Which message to add to the newly created comment.
81        message: String,
82        /// Which files to add to newly created comment.
83        files: Vec<File>,
84    },
85    /// Creating an Issue.
86    Create {
87        /// Which title to start this issue on.
88        title: String,
89        /// Which body to start this issue on.
90        message: String,
91        /// Which files are associated with the issue.
92        files: Vec<File>,
93    },
94    /// Editing a comment.
95    EditComment {
96        /// The comment to edit.
97        target: EntityId<Issue>,
98        /// The message after editing.
99        message: String,
100        /// Which files this comment now has.
101        files: Vec<File>,
102    },
103    /// Changing labels.
104    LabelChange {
105        /// Added labels.
106        added: Vec<Label>,
107        /// Removed labels.
108        removed: Vec<Label>,
109    },
110    /// Setting metadata.
111    SetMetadata {
112        /// Which operation to add metadata to.
113        target: Id,
114        /// Which metadata to add.
115        // Use a vec, to keep stable ordering.
116        new_metadata: Vec<(String, String)>,
117    },
118    /// Setting the status.
119    SetStatus {
120        /// The new status.
121        status: Status,
122    },
123    /// Setting the title.
124    SetTitle {
125        /// The new title.
126        title: String,
127        /// The previous title.
128        was: String,
129    },
130
131    /// Doing nothing.
132    ///
133    /// # Note
134    /// This does nothing, and is as such very useful, for attaching metadata to it.
135    Noop {},
136}
137
138impl OperationData for IssueOperationData {
139    type DecodeError = decode::Error;
140
141    fn is_root(&self) -> bool {
142        matches!(self, IssueOperationData::Create { .. })
143    }
144
145    fn from_value(raw: owned::Value, predicted_type: u64) -> Result<Self, Self::DecodeError>
146    where
147        Self: Sized,
148    {
149        let r#type = IssueOperationType::try_from(predicted_type)?;
150
151        let object = raw
152            .try_into_object()
153            .map_err(|err| decode::Error::ObjectExpected { err })?;
154
155        match r#type {
156            IssueOperationType::AddComment => op::add_comment(object),
157            IssueOperationType::Create => op::create(object),
158            IssueOperationType::EditComment => op::edit_comment(object),
159            IssueOperationType::LabelChange => op::label_change(object),
160            IssueOperationType::NoOp => Ok(op::noop()),
161            IssueOperationType::SetMetadata => op::set_metadata(object),
162            IssueOperationType::SetStatus => op::set_status(object),
163            IssueOperationType::SetTitle => op::set_title(object),
164        }
165    }
166
167    fn as_value(&self) -> simd_json::borrowed::Object<'_> {
168        match self {
169            IssueOperationData::AddComment { message, files } => {
170                op::add_comment_value(message, files)
171            }
172            IssueOperationData::Create {
173                title,
174                message,
175                files,
176            } => op::create_value(title, message, files),
177            IssueOperationData::EditComment {
178                target,
179                message,
180                files,
181            } => op::edit_comment_value(target, message, files),
182            IssueOperationData::LabelChange { added, removed } => {
183                op::label_change_value(added, removed)
184            }
185            IssueOperationData::SetMetadata {
186                target,
187                new_metadata,
188            } => op::set_metadata_value(target, new_metadata),
189            IssueOperationData::SetStatus { status } => op::set_status_value(status),
190            IssueOperationData::SetTitle { title, was } => op::set_title_value(title, was),
191            IssueOperationData::Noop {} => op::noop_value(),
192        }
193    }
194
195    fn to_json_type(&self) -> u64 {
196        IssueOperationType::from(self).into()
197    }
198}
199
200#[allow(missing_docs)]
201pub mod decode {
202
203    use super::file_parse;
204    use crate::{
205        entities::issue::data::{label, status},
206        replica::entity::id,
207    };
208
209    #[derive(Debug, thiserror::Error)]
210    pub enum Error {
211        #[error("The json value did not contain the expected field '{field}'.")]
212        MissingJsonField { field: &'static str },
213
214        #[error("Expected to parse the '{field}' field in the json data: {err}")]
215        WrongJsonType {
216            err: simd_json::TryTypeError,
217            field: &'static str,
218        },
219
220        #[error("Failed to decode an object id (e.g., for a file blob): {0}")]
221        ObjectIdDecode(#[from] gix::hash::decode::Error),
222
223        #[error("Failed to decode an entity id (e.g., for a target field): {0}")]
224        IdDecode(#[from] id::decode::Error),
225
226        #[error(transparent)]
227        InvalidType(#[from] crate::entities::issue::issue_operation::operation_type::decode::Error),
228
229        #[error("Failed to parse the `files` field: {0}")]
230        FileParse(#[from] file_parse::Error),
231        #[error("Failed to parse the `added` or `removed` label fields: {0}")]
232        LabelParse(#[from] label::value_parse::Error),
233        #[error("Failed to parse the status fields: {0}")]
234        StatusParse(#[from] status::decode::Error),
235
236        #[error("Expected this operation json data to be a object, but was not: {err}")]
237        ObjectExpected { err: simd_json::TryTypeError },
238    }
239}