1#![deny(missing_docs)]
12
13use std::{
14 fmt,
15 hash::{Hash, Hasher},
16 io::{BufRead, BufReader, Read, Write},
17 str::FromStr,
18};
19
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use uuid::Uuid;
23
24use radicle::{
25 Profile,
26 identity::Did,
27 node::{Alias, AliasStore},
28 patch::{self, RevisionId},
29 storage::{ReadRepository, ReadStorage, git::paths},
30};
31pub use radicle::{
32 cob::patch::PatchId,
33 prelude::{NodeId, RepoId},
34};
35pub use radicle_surf::Commit;
36
37use crate::{
38 ci_event::{CiEvent, CiEventV1},
39 ergo::Oid,
40 logger,
41};
42
43const PROTOCOL_VERSION: usize = 1;
46
47#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
57pub struct RunId {
58 id: String,
59}
60
61impl Default for RunId {
62 fn default() -> Self {
63 Self {
64 id: Uuid::new_v4().to_string(),
65 }
66 }
67}
68
69impl Hash for RunId {
70 fn hash<H: Hasher>(&self, h: &mut H) {
71 self.id.hash(h);
72 }
73}
74
75impl From<&str> for RunId {
76 fn from(id: &str) -> Self {
77 Self { id: id.into() }
78 }
79}
80
81impl TryFrom<Value> for RunId {
82 type Error = ();
83 fn try_from(id: Value) -> Result<Self, Self::Error> {
84 match id {
85 Value::String(s) => Ok(Self::from(s.as_str())),
86 _ => Err(()),
87 }
88 }
89}
90
91impl fmt::Display for RunId {
92 fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
93 write!(f, "{}", self.id)
94 }
95}
96
97impl RunId {
98 pub fn as_str(&self) -> &str {
100 &self.id
101 }
102}
103
104#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107#[serde(rename_all = "snake_case")]
108#[non_exhaustive]
109pub enum RunResult {
110 Success,
112
113 Failure,
115}
116
117impl fmt::Display for RunResult {
118 fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
119 match self {
120 Self::Failure => write!(f, "failure"),
121 Self::Success => write!(f, "success"),
122 }
123 }
124}
125
126#[derive(Debug, Default)]
128pub struct RequestBuilder<'a> {
129 profile: Option<&'a Profile>,
130 ci_event: Option<&'a CiEvent>,
131}
132
133impl<'a> RequestBuilder<'a> {
134 pub fn profile(mut self, profile: &'a Profile) -> Self {
136 self.profile = Some(profile);
137 self
138 }
139
140 pub fn ci_event(mut self, event: &'a CiEvent) -> Self {
142 self.ci_event = Some(event);
143 self
144 }
145
146 pub fn build_trigger_from_ci_event(self) -> Result<Request, MessageError> {
148 fn repository(repo: &RepoId, profile: &Profile) -> Result<Repository, MessageError> {
149 let rad_repo = match profile.storage.repository(*repo) {
150 Err(err) => {
151 return Err(MessageError::repository_error(err));
152 }
153 Ok(rad_repo) => rad_repo,
154 };
155
156 let project_info = match rad_repo.project() {
157 Err(err) => {
158 return Err(MessageError::repository_error(err));
159 }
160 Ok(x) => x,
161 };
162 let identity = rad_repo
163 .identity()
164 .map_err(MessageError::repository_error)?;
165 let delegates = rad_repo
166 .delegates()
167 .map_err(MessageError::repository_error)?;
168 Ok(Repository {
169 id: *repo,
170 name: project_info.name().to_string(),
171 description: project_info.description().to_string(),
172 private: !identity.visibility().is_public(),
173 default_branch: project_info.default_branch().to_string(),
174 delegates: delegates.iter().copied().collect(),
175 })
176 }
177
178 fn common_fields(
179 event_type: EventType,
180 repo: &RepoId,
181 profile: &Profile,
182 ) -> Result<EventCommonFields, MessageError> {
183 let repository = match repository(repo, profile) {
184 Err(err) => {
185 return Err(err)?;
186 }
187 Ok(x) => x,
188 };
189 Ok(EventCommonFields {
190 version: PROTOCOL_VERSION,
191 event_type,
192 repository,
193 })
194 }
195
196 fn author(node: &NodeId, profile: &Profile) -> Result<Author, MessageError> {
197 let did = Did::from(*node);
198 did_to_author(profile, &did)
199 }
200
201 fn commits(
202 git_repo: &radicle_surf::Repository,
203 tip: Oid,
204 base: Oid,
205 ) -> Result<Vec<Oid>, radicle_surf::Error> {
206 #[allow(clippy::unwrap_used)]
215 let commit = {
216 let ext_oid = radicle_surf::Oid::try_from(tip.as_ref()).unwrap();
217 git_repo.commit(ext_oid).unwrap()
218 };
219 git_repo
220 .history(commit)?
221 .take_while(|c| {
222 if let Ok(c) = c {
223 c.id.as_bytes() != base.as_ref()
224 } else {
225 false
226 }
227 })
228 .filter_map(|result| {
229 if let Ok(commit) = result {
230 if let Ok(oid) = Oid::from_str(&commit.id.to_string()) {
231 Some(Ok(oid))
232 } else {
233 None
234 }
235 } else {
236 None
237 }
238 })
239 .collect::<Result<Vec<Oid>, _>>()
240 }
241
242 fn patch_cob(
243 rad_repo: &radicle::storage::git::Repository,
244 patch_id: &PatchId,
245 ) -> Result<radicle::cob::patch::Patch, MessageError> {
246 let x = match patch::Patches::open(rad_repo) {
247 Err(err) => {
248 return Err(MessageError::repository_error(err))?;
249 }
250 Ok(x) => x,
251 };
252
253 let x = match x.get(patch_id) {
254 Err(err) => {
255 return Err(MessageError::cob_store_error(err))?;
256 }
257 Ok(x) => x,
258 };
259
260 let x = match x {
261 None => {
262 logger::patch_cob_lookup(&rad_repo.id, patch_id);
263 return Err(MessageError::PatchCob(*patch_id));
264 }
265 Some(x) => x,
266 };
267
268 Ok(x)
269 }
270
271 fn revisions(
272 patch_cob: &radicle::cob::patch::Patch,
273 author: &Author,
274 ) -> Result<Vec<Revision>, MessageError> {
275 patch_cob
276 .revisions()
277 .map(|(rid, r)| {
278 Ok::<Revision, MessageError>(Revision {
279 id: rid.into(),
280 author: author.clone(),
281 description: r.description().to_string(),
282 base: *r.base(),
283 oid: r.head(),
284 timestamp: r.timestamp().as_secs(),
285 })
286 })
287 .collect::<Result<Vec<Revision>, MessageError>>()
288 }
289
290 fn patch_base(
291 patch_cob: &radicle::cob::patch::Patch,
292 patch_id: &PatchId,
293 author: &Author,
294 ) -> Result<Oid, MessageError> {
295 let author_pk = radicle::crypto::PublicKey::from(author.id);
296 let (_id, revision) = match patch_cob.latest_by(&author_pk) {
297 None => {
298 return Err(MessageError::LatestPatchRevision(*patch_id));
299 }
300 Some(x) => x,
301 };
302 Ok(*revision.base())
303 }
304
305 let profile = self.profile.ok_or(MessageError::NoProfile)?;
306
307 match self.ci_event {
308 None => Err(MessageError::CiEventNotSet),
309 Some(CiEvent::V1(CiEventV1::BranchCreated {
310 from_node,
311 repo,
312 branch,
313 tip,
314 })) => {
315 Ok(Request::Trigger {
316 common: common_fields(EventType::Push, repo, profile)?,
317 push: Some(PushEvent {
318 pusher: author(from_node, profile)?,
319 before: *tip, after: *tip,
321 branch: branch.as_str().to_string(),
322 commits: vec![*tip], }),
324 patch: None,
325 })
326 }
327 Some(CiEvent::V1(CiEventV1::BranchUpdated {
328 from_node,
329 repo,
330 branch,
331 tip,
332 old_tip,
333 })) => {
334 let git_repo =
335 radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
336 .map_err(MessageError::radicle_surf_error)?;
337 let mut commits =
338 commits(&git_repo, *tip, *old_tip).map_err(MessageError::radicle_surf_error)?;
339 if commits.is_empty() {
340 commits = vec![*old_tip];
341 }
342
343 Ok(Request::Trigger {
344 common: common_fields(EventType::Push, repo, profile)?,
345 push: Some(PushEvent {
346 pusher: author(from_node, profile)?,
347 before: *tip, after: *tip,
349 branch: branch.as_str().to_string(),
350 commits,
351 }),
352 patch: None,
353 })
354 }
355 Some(CiEvent::V1(CiEventV1::BranchDeleted {
356 from_node,
357 repo,
358 branch,
359 tip,
360 })) => {
361 Ok(Request::Trigger {
362 common: common_fields(EventType::Push, repo, profile)?,
363 push: Some(PushEvent {
364 pusher: author(from_node, profile)?,
365 before: *tip, after: *tip,
367 branch: branch.as_str().to_string(),
368 commits: vec![*tip],
369 }),
370 patch: None,
371 })
372 }
373 Some(CiEvent::V1(CiEventV1::TagCreated {
374 from_node,
375 repo,
376 tag,
377 tip,
378 })) => {
379 Ok(Request::Trigger {
380 common: common_fields(EventType::Push, repo, profile)?,
381 push: Some(PushEvent {
382 pusher: author(from_node, profile)?,
383 before: *tip, after: *tip,
385 branch: tag.as_str().to_string(),
386 commits: vec![*tip], }),
388 patch: None,
389 })
390 }
391 Some(CiEvent::V1(CiEventV1::TagUpdated {
392 from_node,
393 repo,
394 tag,
395 tip,
396 old_tip,
397 })) => {
398 let git_repo =
399 radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
400 .map_err(MessageError::radicle_surf_error)?;
401 let mut commits =
402 commits(&git_repo, *tip, *old_tip).map_err(MessageError::radicle_surf_error)?;
403 if commits.is_empty() {
404 commits = vec![*old_tip];
405 }
406
407 Ok(Request::Trigger {
408 common: common_fields(EventType::Push, repo, profile)?,
409 push: Some(PushEvent {
410 pusher: author(from_node, profile)?,
411 before: *tip, after: *tip,
413 branch: tag.as_str().to_string(),
414 commits,
415 }),
416 patch: None,
417 })
418 }
419 Some(CiEvent::V1(CiEventV1::TagDeleted {
420 from_node,
421 repo,
422 tag,
423 tip,
424 })) => {
425 Ok(Request::Trigger {
426 common: common_fields(EventType::Push, repo, profile)?,
427 push: Some(PushEvent {
428 pusher: author(from_node, profile)?,
429 before: *tip, after: *tip,
431 branch: tag.as_str().to_string(),
432 commits: vec![*tip],
433 }),
434 patch: None,
435 })
436 }
437 Some(CiEvent::V1(CiEventV1::PatchCreated {
438 from_node,
439 repo,
440 patch: patch_id,
441 new_tip,
442 })) => {
443 let rad_repo = profile
444 .storage
445 .repository(*repo)
446 .map_err(MessageError::repository_error)?;
447 let git_repo =
448 radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
449 .map_err(MessageError::radicle_surf_error)?;
450 let author = author(from_node, profile)?;
451 let patch_cob = patch_cob(&rad_repo, patch_id)?;
452 let revisions = revisions(&patch_cob, &author)?;
453 let patch_base = patch_base(&patch_cob, patch_id, &author)?;
454 let commits = commits(&git_repo, *new_tip, patch_base)
455 .map_err(MessageError::radicle_surf_error)?;
456
457 Ok(Request::Trigger {
458 common: common_fields(EventType::Patch, repo, profile)?,
459 push: None,
460 patch: Some(PatchEvent {
461 action: PatchAction::Created,
462 patch: Patch {
463 id: **patch_id,
464 author,
465 title: patch_cob.title().to_string(),
466 state: State {
467 status: patch_cob.state().to_string(),
468 conflicts: match patch_cob.state() {
469 patch::State::Open { conflicts, .. } => conflicts.to_vec(),
470 _ => vec![],
471 },
472 },
473 before: patch_base,
474 after: *new_tip,
475 commits,
476 target: patch_cob
477 .target()
478 .head(&rad_repo)
479 .map_err(MessageError::repository_error)?,
480 labels: patch_cob.labels().map(|l| l.name().to_string()).collect(),
481 assignees: patch_cob.assignees().collect(),
482 revisions,
483 },
484 }),
485 })
486 }
487 Some(CiEvent::V1(CiEventV1::PatchUpdated {
488 from_node,
489 repo,
490 patch: patch_id,
491 new_tip,
492 })) => {
493 let rad_repo = profile
494 .storage
495 .repository(*repo)
496 .map_err(MessageError::repository_error)?;
497 let git_repo =
498 radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
499 .map_err(MessageError::radicle_surf_error)?;
500 let author = author(from_node, profile)?;
501 let patch_cob = patch_cob(&rad_repo, patch_id)?;
502 let revisions = revisions(&patch_cob, &author)?;
503 let patch_base = patch_base(&patch_cob, patch_id, &author)?;
504 let commits = commits(&git_repo, *new_tip, patch_base)
505 .map_err(MessageError::radicle_surf_error)?;
506
507 Ok(Request::Trigger {
508 common: common_fields(EventType::Patch, repo, profile)?,
509 push: None,
510 patch: Some(PatchEvent {
511 action: PatchAction::Updated,
512 patch: Patch {
513 id: **patch_id,
514 author,
515 title: patch_cob.title().to_string(),
516 state: State {
517 status: patch_cob.state().to_string(),
518 conflicts: match patch_cob.state() {
519 patch::State::Open { conflicts, .. } => conflicts.to_vec(),
520 _ => vec![],
521 },
522 },
523 before: patch_base,
524 after: *new_tip,
525 commits,
526 target: patch_cob
527 .target()
528 .head(&rad_repo)
529 .map_err(MessageError::repository_error)?,
530 labels: patch_cob.labels().map(|l| l.name().to_string()).collect(),
531 assignees: patch_cob.assignees().collect(),
532 revisions,
533 },
534 }),
535 })
536 }
537 Some(event) => Err(MessageError::UnknownCiEvent(event.clone())),
538 }
539 }
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
544#[serde(tag = "request")]
545#[serde(rename_all = "snake_case")]
546#[non_exhaustive]
547pub enum Request {
548 Trigger {
550 #[serde(flatten)]
552 common: EventCommonFields,
553
554 #[serde(flatten)]
556 push: Option<PushEvent>,
557
558 #[serde(flatten)]
560 patch: Option<PatchEvent>,
561 },
562}
563
564impl Request {
565 pub fn repo(&self) -> RepoId {
567 match self {
568 Self::Trigger {
569 common,
570 push: _,
571 patch: _,
572 } => common.repository.id,
573 }
574 }
575
576 pub fn commit(&self) -> Result<Oid, MessageError> {
579 match self {
580 Self::Trigger {
581 common: _,
582 push,
583 patch,
584 } => {
585 if let Some(push) = push {
586 if let Some(oid) = push.commits.first() {
587 Ok(*oid)
588 } else {
589 Err(MessageError::NoCommits)
590 }
591 } else if let Some(patch) = patch {
592 if let Some(oid) = patch.patch.commits.first() {
593 Ok(*oid)
594 } else {
595 Err(MessageError::NoCommits)
596 }
597 } else {
598 Err(MessageError::UnknownRequest)
599 }
600 }
601 }
602 }
603
604 pub fn to_json_pretty(&self) -> Result<String, MessageError> {
607 serde_json::to_string_pretty(&self).map_err(MessageError::serialize_request)
608 }
609
610 pub fn to_writer<W: Write>(&self, mut writer: W) -> Result<(), MessageError> {
613 let mut line = serde_json::to_string(&self).map_err(MessageError::serialize_request)?;
614 line.push('\n');
615 writer
616 .write(line.as_bytes())
617 .map_err(MessageError::WriteRequest)?;
618 Ok(())
619 }
620
621 pub fn from_reader<R: Read>(reader: R) -> Result<Self, MessageError> {
624 let mut line = String::new();
625 let mut r = BufReader::new(reader);
626 r.read_line(&mut line).map_err(MessageError::ReadLine)?;
627 let req: Self =
628 serde_json::from_slice(line.as_bytes()).map_err(MessageError::deserialize_request)?;
629 Ok(req)
630 }
631
632 pub fn try_from_str(s: &str) -> Result<Self, MessageError> {
634 let req: Self =
635 serde_json::from_slice(s.as_bytes()).map_err(MessageError::deserialize_request)?;
636 Ok(req)
637 }
638}
639
640fn did_to_author(profile: &Profile, did: &Did) -> Result<Author, MessageError> {
641 let alias = profile.aliases().alias(did);
642 Ok(Author { id: *did, alias })
643}
644
645impl fmt::Display for Request {
646 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
647 write!(
648 f,
649 "{}",
650 serde_json::to_string(&self).map_err(|_| fmt::Error)?
651 )
652 }
653}
654
655#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
657#[serde(rename_all = "lowercase")]
658pub enum EventType {
659 Push,
661
662 Patch,
664
665 Tag,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
671pub struct EventCommonFields {
672 pub version: usize,
674
675 pub event_type: EventType,
677
678 pub repository: Repository,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
684pub struct PushEvent {
685 pub pusher: Author,
687
688 pub before: Oid,
690
691 pub after: Oid,
693
694 pub branch: String,
696
697 pub commits: Vec<Oid>,
699}
700
701#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct PatchEvent {
704 pub action: PatchAction,
706
707 pub patch: Patch,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
713pub enum PatchAction {
714 Created,
716
717 Updated,
719}
720
721#[cfg(test)]
722impl PatchAction {
723 fn as_str(&self) -> &str {
724 match self {
725 Self::Created => "created",
726 Self::Updated => "updated",
727 }
728 }
729}
730
731impl TryFrom<&str> for PatchAction {
732 type Error = MessageError;
733 fn try_from(value: &str) -> Result<Self, Self::Error> {
734 match value {
735 "created" => Ok(Self::Created),
736 "updated" => Ok(Self::Updated),
737 _ => Err(Self::Error::UnknownPatchAction(value.into())),
738 }
739 }
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize)]
745pub struct Repository {
746 pub id: RepoId,
748
749 pub name: String,
751
752 pub description: String,
754
755 pub private: bool,
757
758 pub default_branch: String,
761
762 pub delegates: Vec<Did>,
765}
766
767#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
769pub struct Author {
770 pub id: Did,
772
773 pub alias: Option<Alias>,
775}
776
777impl std::fmt::Display for Author {
778 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
779 write!(f, "{}", self.id)?;
780 if let Some(alias) = &self.alias {
781 write!(f, " ({alias})")?;
782 }
783 Ok(())
784 }
785}
786
787#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
789pub struct State {
790 pub status: String,
792
793 pub conflicts: Vec<(RevisionId, Oid)>,
795}
796
797#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
799pub struct Revision {
800 pub id: Oid,
802
803 pub author: Author,
805
806 pub description: String,
808
809 pub base: Oid,
812
813 pub oid: Oid,
815
816 pub timestamp: u64,
818}
819
820impl std::fmt::Display for Revision {
821 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
822 write!(f, "{}", self.id)
823 }
824}
825
826#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
828pub struct Patch {
829 pub id: Oid,
831
832 pub author: Author,
834
835 pub title: String,
837
838 pub state: State,
840
841 pub before: Oid,
843
844 pub after: Oid,
846
847 pub commits: Vec<Oid>,
849
850 pub target: Oid,
852
853 pub labels: Vec<String>,
855
856 pub assignees: Vec<Did>,
858
859 pub revisions: Vec<Revision>,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize)]
865#[serde(deny_unknown_fields)]
866#[serde(rename_all = "snake_case")]
867#[serde(tag = "response")]
868pub enum Response {
869 Triggered {
871 run_id: RunId,
873
874 info_url: Option<String>,
876 },
877
878 Finished {
880 result: RunResult,
882 },
883}
884
885impl Response {
886 pub fn triggered(run_id: RunId) -> Self {
888 Self::Triggered {
889 run_id,
890 info_url: None,
891 }
892 }
893
894 pub fn triggered_with_url(run_id: RunId, url: &str) -> Self {
896 Self::Triggered {
897 run_id,
898 info_url: Some(url.into()),
899 }
900 }
901
902 pub fn finished(result: RunResult) -> Self {
904 Self::Finished { result }
905 }
906
907 pub fn result(&self) -> Option<&RunResult> {
909 if let Self::Finished { result } = self {
910 Some(result)
911 } else {
912 None
913 }
914 }
915
916 pub fn to_writer<W: Write>(&self, mut writer: W) -> Result<(), MessageError> {
919 let mut line = serde_json::to_string(&self).map_err(MessageError::serialize_response)?;
920 line.push('\n');
921 writer
922 .write(line.as_bytes())
923 .map_err(MessageError::WriteResponse)?;
924 Ok(())
925 }
926
927 pub fn to_json_pretty(&self) -> Result<String, MessageError> {
930 serde_json::to_string_pretty(&self).map_err(MessageError::serialize_response)
931 }
932
933 pub fn from_reader<R: Read + BufRead>(reader: &mut R) -> Result<Option<Self>, MessageError> {
936 let mut line = String::new();
937 let mut r = BufReader::new(reader);
938 let n = r.read_line(&mut line).map_err(MessageError::ReadLine)?;
939 if n == 0 {
940 Ok(None)
942 } else {
943 let req: Self = serde_json::from_slice(line.as_bytes())
944 .map_err(MessageError::deserialize_response)?;
945 Ok(Some(req))
946 }
947 }
948
949 #[allow(clippy::should_implement_trait)]
952 pub fn from_str(line: &str) -> Result<Self, MessageError> {
953 let req: Self =
954 serde_json::from_slice(line.as_bytes()).map_err(MessageError::deserialize_response)?;
955 Ok(req)
956 }
957}
958
959#[derive(Debug, thiserror::Error)]
961pub enum MessageError {
962 #[error("RequestBuilder must have profile set")]
964 NoProfile,
965
966 #[error("RequestBuilder must have broker event set")]
968 NoEvent,
969
970 #[error("RequestBuilder has no event handler set")]
972 NoEventHandler,
973
974 #[error("programming error: unknown CI event {0:?}")]
976 UnknownCiEvent(CiEvent),
977
978 #[error("programming error: CI event was not set for request builder")]
980 CiEventNotSet,
981
982 #[error("unacceptable request message: lacks Git commits to run CI on")]
984 NoCommits,
985
986 #[error("unacceptable request message: neither 'push' nor 'patch'")]
988 UnknownRequest,
989
990 #[error("failed to serialize a request into JSON to a file handle")]
993 SerializeRequest(#[source] Box<dyn std::error::Error + Send + 'static>),
994
995 #[error("failed to serialize a request into JSON to a file handle")]
998 SerializeResponse(#[source] Box<dyn std::error::Error + Send + 'static>),
999
1000 #[error("failed to write JSON to file handle")]
1002 WriteRequest(#[source] std::io::Error),
1003
1004 #[error("failed to write JSON to file handle")]
1007 WriteResponse(#[source] std::io::Error),
1008
1009 #[error("failed to read line from file handle")]
1011 ReadLine(#[source] std::io::Error),
1012
1013 #[error("failed to read a JSON request from a file handle")]
1015 DeserializeRequest(#[source] Box<dyn std::error::Error + Send + 'static>),
1016
1017 #[error("failed to read a JSON response from a file handle")]
1019 DeserializeResponse(#[source] Box<dyn std::error::Error + Send + 'static>),
1020
1021 #[error("could not generate trigger from event")]
1023 Trigger,
1024
1025 #[error("could look up patch COB {0}: not found?")]
1027 PatchCob(PatchId),
1028
1029 #[error("failed to look up latest revision for patch {0}")]
1031 LatestPatchRevision(PatchId),
1032
1033 #[error("error from Radicle repository")]
1035 RepositoryError(#[source] Box<dyn std::error::Error + Send + 'static>),
1036
1037 #[error("error from Radicle collaborative object")]
1039 CobStoreError(#[source] Box<dyn std::error::Error + Send + 'static>),
1040
1041 #[error("error from radicle-surf")]
1043 RadicleSurfError(#[source] Box<dyn std::error::Error + Send + 'static>),
1044
1045 #[error("invalid patch action {0:?}")]
1047 UnknownPatchAction(String),
1048}
1049
1050impl MessageError {
1051 fn serialize_request(err: serde_json::Error) -> Self {
1052 Self::SerializeRequest(Box::new(err))
1053 }
1054
1055 fn serialize_response(err: serde_json::Error) -> Self {
1056 Self::SerializeResponse(Box::new(err))
1057 }
1058
1059 fn deserialize_request(err: serde_json::Error) -> Self {
1060 Self::DeserializeRequest(Box::new(err))
1061 }
1062
1063 fn deserialize_response(err: serde_json::Error) -> Self {
1064 Self::DeserializeResponse(Box::new(err))
1065 }
1066
1067 fn repository_error(err: radicle::storage::RepositoryError) -> Self {
1068 Self::RepositoryError(Box::new(err))
1069 }
1070
1071 fn cob_store_error(err: radicle::cob::store::Error) -> Self {
1072 Self::CobStoreError(Box::new(err))
1073 }
1074
1075 fn radicle_surf_error(err: radicle_surf::Error) -> Self {
1076 Self::RadicleSurfError(Box::new(err))
1077 }
1078}
1079
1080#[cfg(test)]
1081#[allow(clippy::unwrap_used)] #[allow(missing_docs)]
1083pub mod trigger_from_ci_event_tests {
1084 use crate::ci_event::{CiEvent, CiEventV1};
1085 use crate::msg::{EventType, Request, RequestBuilder};
1086 use git_ref_format_core::RefString;
1087 use radicle::cob::Title;
1088 use radicle::patch::{MergeTarget, Patches};
1089 use radicle::prelude::Did;
1090 use radicle::storage::ReadRepository;
1091
1092 use crate::test::{MockNode, TestResult};
1093
1094 #[test]
1095 fn trigger_push_from_branch_created() -> TestResult<()> {
1096 let mock_node = MockNode::new()?;
1097 let profile = mock_node.profile()?;
1098
1099 let project = mock_node.node().project();
1100 let (_, repo_head) = project.repo.head()?;
1101 let cmt = radicle::test::fixtures::commit(
1102 "my test commit",
1103 &[repo_head.into()],
1104 &project.backend,
1105 );
1106
1107 let ci_event = CiEvent::V1(CiEventV1::BranchCreated {
1108 from_node: *profile.id(),
1109 repo: project.id,
1110 branch: RefString::try_from("master")?,
1111 tip: cmt,
1112 });
1113
1114 let req = RequestBuilder::default()
1115 .profile(&profile)
1116 .ci_event(&ci_event)
1117 .build_trigger_from_ci_event()?;
1118 let Request::Trigger {
1119 common,
1120 push,
1121 patch,
1122 } = req;
1123
1124 assert!(patch.is_none());
1125 assert!(push.is_some());
1126 assert_eq!(common.event_type, EventType::Push);
1127 assert_eq!(common.repository.id, project.id);
1128 assert_eq!(common.repository.name, project.repo.project()?.name());
1129
1130 let push = push.unwrap();
1131 assert_eq!(push.after, cmt);
1132 assert_eq!(push.before, cmt); assert_eq!(
1134 push.branch,
1135 "master".replace("$nid", &profile.id().to_string())
1136 );
1137 assert_eq!(push.commits, vec![cmt]);
1138 assert_eq!(push.pusher.id, Did::from(profile.id()));
1139
1140 Ok(())
1141 }
1142
1143 #[test]
1144 fn trigger_push_from_branch_updated() -> TestResult<()> {
1145 let mock_node = MockNode::new()?;
1146 let profile = mock_node.profile()?;
1147
1148 let project = mock_node.node().project();
1149 let (_, repo_head) = project.repo.head()?;
1150 let cmt = radicle::test::fixtures::commit(
1151 "my test commit",
1152 &[repo_head.into()],
1153 &project.backend,
1154 );
1155
1156 let ci_event = CiEvent::V1(CiEventV1::BranchUpdated {
1157 from_node: *profile.id(),
1158 repo: project.id,
1159 branch: RefString::try_from("master")?,
1160 old_tip: repo_head,
1161 tip: cmt,
1162 });
1163
1164 let req = RequestBuilder::default()
1165 .profile(&profile)
1166 .ci_event(&ci_event)
1167 .build_trigger_from_ci_event()?;
1168 let Request::Trigger {
1169 common,
1170 push,
1171 patch,
1172 } = req;
1173
1174 assert!(patch.is_none());
1175 assert!(push.is_some());
1176 assert_eq!(common.event_type, EventType::Push);
1177 assert_eq!(common.repository.id, project.id);
1178 assert_eq!(common.repository.name, project.repo.project()?.name());
1179
1180 let push = push.unwrap();
1181 assert_eq!(push.after, cmt);
1182 assert_eq!(push.before, cmt); assert_eq!(
1184 push.branch,
1185 "master".replace("$nid", &profile.id().to_string())
1186 );
1187 assert_eq!(push.commits, vec![cmt]);
1188 assert_eq!(push.pusher.id, Did::from(profile.id()));
1189
1190 Ok(())
1191 }
1192
1193 #[test]
1194 fn trigger_patch_from_patch_created() -> TestResult<()> {
1195 let mock_node = MockNode::new()?;
1196 let profile = mock_node.profile()?;
1197
1198 let project = mock_node.node().project();
1199 let (_, repo_head) = project.repo.head()?;
1200 let cmt = radicle::test::fixtures::commit(
1201 "my test commit",
1202 &[repo_head.into()],
1203 &project.backend,
1204 );
1205
1206 let node = mock_node.node();
1207
1208 let mut patches = Patches::open(&project.repo)?;
1209 let mut cache = radicle::cob::cache::NoCache;
1210 let patch_cob = patches.create(
1211 Title::new("my patch title").unwrap(),
1212 "my patch description",
1213 MergeTarget::Delegates,
1214 repo_head,
1215 cmt,
1216 &[],
1217 &mut cache,
1218 &node.signer,
1219 )?;
1220
1221 let ci_event = CiEvent::V1(CiEventV1::PatchCreated {
1222 from_node: *profile.id(),
1223 repo: project.id,
1224 patch: *patch_cob.id(),
1225 new_tip: cmt,
1226 });
1227
1228 let req = RequestBuilder::default()
1229 .profile(&profile)
1230 .ci_event(&ci_event)
1231 .build_trigger_from_ci_event()?;
1232 let Request::Trigger {
1233 common,
1234 push,
1235 patch,
1236 } = req;
1237
1238 assert!(patch.is_some());
1239 assert!(push.is_none());
1240 assert_eq!(common.event_type, EventType::Patch);
1241 assert_eq!(common.repository.id, project.id);
1242 assert_eq!(common.repository.name, project.repo.project()?.name());
1243
1244 let patch = patch.unwrap();
1245 assert_eq!(patch.action.as_str(), "created");
1246 assert_eq!(patch.patch.id.to_string(), patch_cob.id.to_string());
1247 assert_eq!(patch.patch.title, patch_cob.title());
1248 assert_eq!(patch.patch.state.status, patch_cob.state().to_string());
1249 assert_eq!(patch.patch.target, repo_head);
1250 assert_eq!(patch.patch.revisions.len(), 1);
1251 let rev = patch.patch.revisions.first().unwrap();
1252 assert_eq!(rev.id.to_string(), patch_cob.id.to_string());
1253 assert_eq!(rev.base, repo_head);
1254 assert_eq!(rev.oid, cmt);
1255 assert_eq!(rev.author.id, Did::from(profile.id()));
1256 assert_eq!(rev.description, patch_cob.description());
1257 assert_eq!(rev.timestamp, patch_cob.timestamp().as_secs());
1258 assert_eq!(patch.patch.after, cmt);
1259 assert_eq!(patch.patch.before, repo_head);
1260 assert_eq!(patch.patch.commits, vec![cmt]);
1261
1262 Ok(())
1263 }
1264
1265 #[test]
1266 fn trigger_patch_from_patch_updated() -> TestResult<()> {
1267 let mock_node = MockNode::new()?;
1268 let profile = mock_node.profile()?;
1269
1270 let project = mock_node.node().project();
1271 let (_, repo_head) = project.repo.head()?;
1272 let cmt = radicle::test::fixtures::commit(
1273 "my test commit",
1274 &[repo_head.into()],
1275 &project.backend,
1276 );
1277
1278 let node = mock_node.node();
1279
1280 let mut patches = Patches::open(&project.repo)?;
1281 let mut cache = radicle::cob::cache::NoCache;
1282 let patch_cob = patches.create(
1283 Title::new("my patch title").unwrap(),
1284 "my patch description",
1285 MergeTarget::Delegates,
1286 repo_head,
1287 cmt,
1288 &[],
1289 &mut cache,
1290 &node.signer,
1291 )?;
1292
1293 let ci_event = CiEvent::V1(CiEventV1::PatchUpdated {
1294 from_node: *profile.id(),
1295 repo: project.id,
1296 patch: *patch_cob.id(),
1297 new_tip: cmt,
1298 });
1299
1300 let req = RequestBuilder::default()
1301 .profile(&profile)
1302 .ci_event(&ci_event)
1303 .build_trigger_from_ci_event()?;
1304 let Request::Trigger {
1305 common,
1306 push,
1307 patch,
1308 } = req;
1309
1310 assert!(patch.is_some());
1311 assert!(push.is_none());
1312 assert_eq!(common.event_type, EventType::Patch);
1313 assert_eq!(common.repository.id, project.id);
1314 assert_eq!(common.repository.name, project.repo.project()?.name());
1315
1316 let patch = patch.unwrap();
1317 assert_eq!(patch.action.as_str(), "updated");
1318 assert_eq!(patch.patch.id.to_string(), patch_cob.id.to_string());
1319 assert_eq!(patch.patch.title, patch_cob.title());
1320 assert_eq!(patch.patch.state.status, patch_cob.state().to_string());
1321 assert_eq!(patch.patch.target, repo_head);
1322 assert_eq!(patch.patch.revisions.len(), 1);
1323 let rev = patch.patch.revisions.first().unwrap();
1324 assert_eq!(rev.id.to_string(), patch_cob.id.to_string());
1325 assert_eq!(rev.base, repo_head);
1326 assert_eq!(rev.oid, cmt);
1327 assert_eq!(rev.author.id, Did::from(profile.id()));
1328 assert_eq!(rev.description, patch_cob.description());
1329 assert_eq!(rev.timestamp, patch_cob.timestamp().as_secs());
1330 assert_eq!(patch.patch.after, cmt);
1331 assert_eq!(patch.patch.before, repo_head);
1332 assert_eq!(patch.patch.commits, vec![cmt]);
1333
1334 Ok(())
1335 }
1336}
1337
1338pub mod helper {
1340
1341 use std::{
1342 fs::{File, OpenOptions},
1343 io::Write,
1344 path::{Path, PathBuf},
1345 process::Command,
1346 };
1347
1348 use nonempty::{NonEmpty, nonempty};
1349 use radicle::prelude::{Profile, RepoId};
1350
1351 use time::{OffsetDateTime, macros::format_description};
1352
1353 use super::{MessageError, Oid, Request, Response, RunId, RunResult};
1354
1355 pub const NO_EXIT: i32 = 999;
1357
1358 pub fn read_request() -> Result<Request, MessageHelperError> {
1360 let req =
1361 Request::from_reader(std::io::stdin()).map_err(MessageHelperError::ReadRequest)?;
1362 Ok(req)
1363 }
1364
1365 fn write_response(resp: &Response) -> Result<(), MessageHelperError> {
1367 resp.to_writer(std::io::stdout())
1368 .map_err(|e| MessageHelperError::WriteResponse(resp.clone(), Box::new(e)))?;
1369 Ok(())
1370 }
1371
1372 pub fn write_triggered(
1374 run_id: &RunId,
1375 info_url: Option<&str>,
1376 ) -> Result<(), MessageHelperError> {
1377 let response = if let Some(url) = info_url {
1378 Response::triggered_with_url(run_id.clone(), url)
1379 } else {
1380 Response::triggered(run_id.clone())
1381 };
1382 write_response(&response)?;
1383 Ok(())
1384 }
1385
1386 pub fn write_failed() -> Result<(), MessageHelperError> {
1388 write_response(&Response::Finished {
1389 result: RunResult::Failure,
1390 })?;
1391 Ok(())
1392 }
1393
1394 pub fn write_succeeded() -> Result<(), MessageHelperError> {
1396 write_response(&Response::Finished {
1397 result: RunResult::Success,
1398 })?;
1399 Ok(())
1400 }
1401
1402 pub fn get_sources(
1404 adminlog: &mut AdminLog,
1405 dry_run: bool,
1406 repoid: RepoId,
1407 commit: Oid,
1408 src: &Path,
1409 ) -> Result<(), MessageHelperError> {
1410 let profile = Profile::load().map_err(MessageHelperError::Profile)?;
1411 let storage = profile.storage.path();
1412 let repo_path = storage.join(repoid.canonical());
1413
1414 git_clone(adminlog, dry_run, &repo_path, src)?;
1415 git_checkout(adminlog, dry_run, commit, src)?;
1416
1417 Ok(())
1418 }
1419
1420 fn git_clone(
1422 adminlog: &mut AdminLog,
1423 dry_run: bool,
1424 repo_path: &Path,
1425 src: &Path,
1426 ) -> Result<(), MessageHelperError> {
1427 let repo_path = repo_path.to_string_lossy();
1428 let src = src.to_string_lossy();
1429 runcmd(
1430 adminlog,
1431 dry_run,
1432 &nonempty!["git", "clone", &repo_path, &src],
1433 Path::new("."),
1434 )?;
1435 Ok(())
1436 }
1437
1438 fn git_checkout(
1440 adminlog: &mut AdminLog,
1441 dry_run: bool,
1442 commit: Oid,
1443 src: &Path,
1444 ) -> Result<(), MessageHelperError> {
1445 runcmd(
1446 adminlog,
1447 dry_run,
1448 &nonempty!["git", "config", "advice.detachedHead", "false"],
1449 src,
1450 )?;
1451 let commit = commit.to_string();
1452 runcmd(
1453 adminlog,
1454 dry_run,
1455 &nonempty!["git", "checkout", &commit],
1456 src,
1457 )?;
1458 Ok(())
1459 }
1460
1461 pub fn runcmd(
1463 adminlog: &mut AdminLog,
1464 dry_run: bool,
1465 argv: &NonEmpty<&str>,
1466 cwd: &Path,
1467 ) -> Result<(i32, Vec<u8>), MessageHelperError> {
1468 if dry_run {
1469 adminlog
1470 .writeln(&format!("runcmd: pretend to run: argv={argv:?}"))
1471 .map_err(MessageHelperError::AdminLog)?;
1472 return Ok((0, vec![]));
1473 }
1474
1475 adminlog.writeln(&format!("runcmd: argv={argv:?}"))?;
1476 let output = Command::new("bash")
1477 .arg("-c")
1478 .arg(r#""$@" 2>&1"#)
1479 .arg("--")
1480 .args(argv)
1481 .current_dir(cwd)
1482 .output()
1483 .map_err(|err| MessageHelperError::Command("bash", err))?;
1484
1485 let exit = output.status.code().unwrap_or(NO_EXIT);
1486 adminlog.writeln(&format!("runcmd: exit={exit}"))?;
1487
1488 if exit != 0 {
1489 indented(adminlog, "stdout", &output.stdout);
1490 indented(adminlog, "stderr", &output.stderr);
1491 }
1492
1493 Ok((exit, output.stdout))
1494 }
1495
1496 pub fn indented(adminlog: &mut AdminLog, msg: &str, bytes: &[u8]) {
1498 if !bytes.is_empty() {
1499 adminlog.writeln(&format!("{msg}:")).ok();
1500 let text = String::from_utf8_lossy(bytes);
1501 for line in text.lines() {
1502 adminlog.writeln(&format!(" {line}")).ok();
1503 }
1504 }
1505 }
1506
1507 #[derive(Debug, Default)]
1510 pub struct AdminLog {
1511 filename: Option<PathBuf>,
1512 file: Option<File>,
1513 stderr: bool,
1514 buffer: Option<Vec<u8>>,
1515 }
1516
1517 impl AdminLog {
1518 pub fn null() -> Self {
1521 Self::default()
1522 }
1523
1524 pub fn stderr() -> Self {
1526 Self {
1527 filename: None,
1528 file: None,
1529 stderr: true,
1530 buffer: None,
1531 }
1532 }
1533
1534 pub fn capture() -> Self {
1536 Self {
1537 filename: None,
1538 file: None,
1539 stderr: false,
1540 buffer: Some(vec![]),
1541 }
1542 }
1543
1544 pub fn capture_buffer(&self) -> Option<&[u8]> {
1546 self.buffer.as_deref()
1547 }
1548
1549 pub fn open(filename: &Path) -> Result<Self, LogError> {
1551 let file = OpenOptions::new()
1552 .append(true)
1553 .create(true)
1554 .open(filename)
1555 .map_err(|e| LogError::OpenLogFile(filename.into(), e))?;
1556 Ok(Self {
1557 filename: Some(filename.into()),
1558 file: Some(file),
1559 stderr: false,
1560 buffer: None,
1561 })
1562 }
1563
1564 pub fn writeln(&mut self, text: &str) -> Result<(), LogError> {
1566 self.write("[")?;
1567 self.write(&now()?)?;
1568 self.write("] ")?;
1569 self.write(text)?;
1570 self.write("\n")?;
1571 Ok(())
1572 }
1573
1574 fn write(&mut self, msg: &str) -> Result<(), LogError> {
1575 if let Some(file) = &mut self.file {
1576 #[allow(clippy::unwrap_used)] file.write_all(msg.as_bytes())
1578 .map_err(|e| LogError::WriteLogFile(self.filename.clone().unwrap(), e))?;
1579 } else if self.stderr {
1580 std::io::stderr()
1581 .write_all(msg.as_bytes())
1582 .map_err(LogError::WriteLogStderr)?;
1583 } else if let Some(buf) = self.buffer.as_mut() {
1584 buf.extend_from_slice(msg.as_bytes());
1585 }
1586 Ok(())
1587 }
1588 }
1589
1590 fn now() -> Result<String, LogError> {
1591 let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
1592 OffsetDateTime::now_utc()
1593 .format(fmt)
1594 .map_err(LogError::TimeFormat)
1595 }
1596
1597 #[derive(Debug, thiserror::Error)]
1599 pub enum LogError {
1600 #[error("failed to open log file {0}")]
1602 OpenLogFile(PathBuf, #[source] std::io::Error),
1603
1604 #[error("failed to write to log file {0}")]
1606 WriteLogFile(PathBuf, #[source] std::io::Error),
1607
1608 #[error("failed to write to log file {0}")]
1610 WriteLogStderr(#[source] std::io::Error),
1611
1612 #[error("failed to format time stamp")]
1614 TimeFormat(#[source] time::error::Format),
1615 }
1616
1617 #[derive(Debug, thiserror::Error)]
1619 pub enum MessageHelperError {
1620 #[error("failed to read request from stdin: {0:?}")]
1622 ReadRequest(#[source] MessageError),
1623
1624 #[error("failed to write response to stdout: {0:?}")]
1626 WriteResponse(Response, #[source] Box<MessageError>),
1627
1628 #[error("failed to load Radicle profile")]
1630 Profile(#[source] radicle::profile::Error),
1631
1632 #[error("failed to run command {0}")]
1634 Command(&'static str, #[source] std::io::Error),
1635
1636 #[error(transparent)]
1638 AdminLog(#[from] LogError),
1639 }
1640}