Skip to main content

shared/models/
egg_repository.rs

1use crate::{
2    censor::{CENSORED_PLACEHOLDER, Censor},
3    models::{InsertQueryBuilder, UpdateQueryBuilder},
4    prelude::*,
5};
6use compact_str::ToCompactString;
7use futures_util::StreamExt;
8use garde::Validate;
9use serde::{Deserialize, Serialize};
10use sqlx::{Row, postgres::PgRow};
11use std::{
12    collections::BTreeMap,
13    path::PathBuf,
14    sync::{Arc, LazyLock},
15};
16use utoipa::ToSchema;
17
18fn validate_git_repository(
19    git_repository: &compact_str::CompactString,
20    _context: &(),
21) -> Result<(), garde::Error> {
22    let url = match gix::url::parse(git_repository.as_str()) {
23        Ok(url) => url,
24        Err(err) => return Err(garde::Error::new(format!("Invalid git repository: {err}"))),
25    };
26
27    match url.scheme {
28        gix::url::Scheme::Http | gix::url::Scheme::Https | gix::url::Scheme::Ssh => {}
29        _ => {
30            return Err(garde::Error::new(
31                "Invalid git repository: only http, https and ssh urls are supported, scp-style urls have to be written as ssh://user@host/path",
32            ));
33        }
34    }
35
36    if url.host().is_none() {
37        return Err(garde::Error::new("Invalid git repository: missing host"));
38    }
39
40    Ok(())
41}
42
43#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
44#[serde(tag = "type", rename_all = "snake_case")]
45pub enum EggRepositoryCredentials {
46    None,
47    Password {
48        #[garde(length(chars, min = 1, max = 255))]
49        #[schema(min_length = 1, max_length = 255)]
50        username: compact_str::CompactString,
51        #[garde(length(chars, min = 1, max = 255))]
52        #[schema(min_length = 1, max_length = 255)]
53        password: compact_str::CompactString,
54    },
55    PrivateKey {
56        #[garde(length(chars, min = 1, max = 255))]
57        #[schema(min_length = 1, max_length = 255)]
58        username: compact_str::CompactString,
59        #[garde(
60            length(chars, min = 1, max = 16384),
61            custom(crate::git::validate_private_key)
62        )]
63        #[schema(min_length = 1, max_length = 16384)]
64        private_key: String,
65        #[garde(inner(length(chars, min = 1, max = 255)))]
66        #[schema(min_length = 1, max_length = 255)]
67        passphrase: Option<compact_str::CompactString>,
68    },
69}
70
71impl EggRepositoryCredentials {
72    fn validate_scheme_for_url(&self, git_repository: &str) -> Result<(), anyhow::Error> {
73        let scheme = gix::url::parse(git_repository)
74            .map(|url| url.scheme)
75            .unwrap_or(gix::url::Scheme::Https);
76
77        match self {
78            EggRepositoryCredentials::None => {}
79            EggRepositoryCredentials::Password { .. } => {
80                if scheme == gix::url::Scheme::Http {
81                    return Err(crate::response::DisplayError::new(
82                        "password credentials cannot be sent over plain http, use an https repository url",
83                    )
84                    .into());
85                }
86            }
87            EggRepositoryCredentials::PrivateKey { .. } => {
88                if scheme != gix::url::Scheme::Ssh {
89                    return Err(crate::response::DisplayError::new(
90                        "private key credentials can only be used with ssh repositories",
91                    )
92                    .into());
93                }
94            }
95        }
96
97        Ok(())
98    }
99
100    fn validate_key_material(&self) -> Result<(), anyhow::Error> {
101        if let EggRepositoryCredentials::PrivateKey {
102            private_key,
103            passphrase,
104            ..
105        } = self
106        {
107            crate::git::parse_private_key(private_key, passphrase.as_deref()).map_err(|err| {
108                crate::response::DisplayError::new(format!("private key is unusable: {err}"))
109            })?;
110        }
111
112        Ok(())
113    }
114
115    pub async fn encrypt(
116        &mut self,
117        database: &crate::database::Database,
118    ) -> Result<(), anyhow::Error> {
119        match self {
120            EggRepositoryCredentials::None => {}
121            EggRepositoryCredentials::Password { password, .. } => {
122                *password = database.encrypt_base64(password.clone()).await?;
123            }
124            EggRepositoryCredentials::PrivateKey {
125                private_key,
126                passphrase,
127                ..
128            } => {
129                *private_key = database.encrypt_base64(private_key.clone()).await?.into();
130
131                if let Some(passphrase) = passphrase {
132                    *passphrase = database.encrypt_base64(passphrase.clone()).await?;
133                }
134            }
135        }
136
137        Ok(())
138    }
139
140    pub async fn decrypt(
141        &mut self,
142        database: &crate::database::Database,
143    ) -> Result<(), anyhow::Error> {
144        match self {
145            EggRepositoryCredentials::None => {}
146            EggRepositoryCredentials::Password { password, .. } => {
147                if let Some(decrypted) = database.decrypt_base64_optional(&password).await? {
148                    *password = decrypted;
149                }
150            }
151            EggRepositoryCredentials::PrivateKey {
152                private_key,
153                passphrase,
154                ..
155            } => {
156                if let Some(decrypted) = database.decrypt_base64_optional(&private_key).await? {
157                    *private_key = decrypted.into();
158                }
159
160                if let Some(passphrase) = passphrase
161                    && let Some(decrypted) = database.decrypt_base64_optional(&passphrase).await?
162                {
163                    *passphrase = decrypted;
164                }
165            }
166        }
167
168        Ok(())
169    }
170}
171
172impl Censor for EggRepositoryCredentials {
173    fn censor(&mut self) {
174        match self {
175            EggRepositoryCredentials::None => {}
176            EggRepositoryCredentials::Password { password, .. } => {
177                *password = CENSORED_PLACEHOLDER.into();
178            }
179            EggRepositoryCredentials::PrivateKey {
180                private_key,
181                passphrase,
182                ..
183            } => {
184                *private_key = CENSORED_PLACEHOLDER.into();
185
186                if let Some(passphrase) = passphrase {
187                    *passphrase = CENSORED_PLACEHOLDER.into();
188                }
189            }
190        }
191    }
192}
193
194impl From<EggRepositoryCredentials> for crate::git::GitCredentials {
195    fn from(value: EggRepositoryCredentials) -> Self {
196        match value {
197            EggRepositoryCredentials::None => Self::None,
198            EggRepositoryCredentials::Password { username, password } => {
199                Self::Password { username, password }
200            }
201            EggRepositoryCredentials::PrivateKey {
202                username,
203                private_key,
204                passphrase,
205            } => Self::PrivateKey {
206                username,
207                private_key,
208                passphrase,
209            },
210        }
211    }
212}
213
214#[derive(Serialize, Deserialize, Clone)]
215pub struct EggRepository {
216    pub uuid: uuid::Uuid,
217
218    pub name: compact_str::CompactString,
219    pub description: Option<compact_str::CompactString>,
220    pub git_repository: compact_str::CompactString,
221    pub credentials: EggRepositoryCredentials,
222
223    pub last_synced: Option<chrono::NaiveDateTime>,
224    pub created: chrono::NaiveDateTime,
225
226    extension_data: super::ModelExtensionData,
227}
228
229impl BaseModel for EggRepository {
230    const NAME: &'static str = "egg_repository";
231
232    fn get_extension_list() -> &'static super::ModelExtensionList {
233        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
234            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
235
236        &EXTENSIONS
237    }
238
239    fn get_extension_data(&self) -> &super::ModelExtensionData {
240        &self.extension_data
241    }
242
243    #[inline]
244    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
245        let prefix = prefix.unwrap_or_default();
246
247        BTreeMap::from([
248            (
249                "egg_repositories.uuid",
250                compact_str::format_compact!("{prefix}uuid"),
251            ),
252            (
253                "egg_repositories.name",
254                compact_str::format_compact!("{prefix}name"),
255            ),
256            (
257                "egg_repositories.description",
258                compact_str::format_compact!("{prefix}description"),
259            ),
260            (
261                "egg_repositories.git_repository",
262                compact_str::format_compact!("{prefix}git_repository"),
263            ),
264            (
265                "egg_repositories.credentials",
266                compact_str::format_compact!("{prefix}credentials"),
267            ),
268            (
269                "egg_repositories.last_synced",
270                compact_str::format_compact!("{prefix}last_synced"),
271            ),
272            (
273                "egg_repositories.created",
274                compact_str::format_compact!("{prefix}created"),
275            ),
276        ])
277    }
278
279    #[inline]
280    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
281        let prefix = prefix.unwrap_or_default();
282
283        Ok(Self {
284            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
285            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
286            description: row
287                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
288            git_repository: row
289                .try_get(compact_str::format_compact!("{prefix}git_repository").as_str())?,
290            credentials: serde_json::from_value(
291                row.try_get(compact_str::format_compact!("{prefix}credentials").as_str())?,
292            )?,
293            last_synced: row
294                .try_get(compact_str::format_compact!("{prefix}last_synced").as_str())?,
295            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
296            extension_data: Self::map_extensions(prefix, row)?,
297        })
298    }
299}
300
301impl EggRepository {
302    pub async fn all_with_pagination(
303        database: &crate::database::Database,
304        page: i64,
305        per_page: i64,
306        search: Option<&str>,
307    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
308        let offset = (page - 1) * per_page;
309
310        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
311            r#"
312            SELECT {}, COUNT(*) OVER() AS total_count
313            FROM egg_repositories
314            WHERE ($1 IS NULL OR egg_repositories.name ILIKE '%' || $1 || '%')
315            ORDER BY egg_repositories.created
316            LIMIT $2 OFFSET $3
317            "#,
318            Self::columns_sql(None)
319        )))
320        .bind(search)
321        .bind(per_page)
322        .bind(offset)
323        .fetch_all(database.read())
324        .await?;
325
326        Ok(super::Pagination {
327            total: rows
328                .first()
329                .map_or(Ok(0), |row| row.try_get("total_count"))?,
330            per_page,
331            page,
332            data: rows
333                .into_iter()
334                .map(|row| Self::map(None, &row))
335                .try_collect_vec()?,
336        })
337    }
338
339    pub async fn sync(&self, state: &crate::State) -> Result<usize, anyhow::Error> {
340        let git_repository = self.git_repository.clone();
341
342        let mut credentials = self.credentials.clone();
343        credentials.decrypt(&state.database).await?;
344
345        let url = gix::url::parse(git_repository.as_str())?;
346        crate::git::resolve_addresses(&state.env, &url).await?;
347
348        let env = Arc::clone(&state.env);
349
350        struct FoundEgg {
351            path: PathBuf,
352            readme: Option<String>,
353            exported_egg: super::nest_egg::ExportedNestEgg,
354            updated: chrono::DateTime<chrono::Utc>,
355        }
356
357        let exported_eggs =
358            tokio::task::spawn_blocking(move || -> Result<Vec<FoundEgg>, anyhow::Error> {
359                let mut exported_eggs = Vec::new();
360                let temp_dir = tempfile::tempdir()?;
361                let filesystem = crate::cap::CapFilesystem::new(temp_dir.path().to_path_buf())?;
362
363                let mut prepare_fetch = gix::clone::PrepareFetch::new(
364                    url.clone(),
365                    temp_dir.path(),
366                    gix::create::Kind::WithWorktree,
367                    Default::default(),
368                    gix::open::Options::default().config_overrides(["credential.helper="]),
369                )?
370                .configure_connection(
371                    crate::git::GitCredentials::from(credentials)
372                        .into_connection_configurator(env, url),
373                );
374
375                let (mut prepare_checkout, _) = prepare_fetch
376                    .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
377                let _ = prepare_checkout
378                    .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
379
380                tracing::info!(
381                    "cloned egg repository {} to temporary directory",
382                    git_repository
383                );
384
385                let mut walker = filesystem.walk_dir(".")?;
386                while let Some(Ok((is_dir, entry))) = walker.next_entry() {
387                    if is_dir
388                        || !matches!(
389                            entry.extension().and_then(|s| s.to_str()),
390                            Some("json") | Some("yml") | Some("yaml")
391                        )
392                    {
393                        continue;
394                    }
395
396                    let metadata = match filesystem.metadata(&entry) {
397                        Ok(metadata) => metadata,
398                        Err(_) => continue,
399                    };
400
401                    if !metadata.is_file()
402                        || metadata.len() > super::nest_egg::NestEgg::MAX_EXPORTED_SIZE as u64
403                    {
404                        continue;
405                    }
406
407                    let file_content = match filesystem.read_to_string(&entry) {
408                        Ok(content) => content,
409                        Err(_) => continue,
410                    };
411                    let exported_egg: super::nest_egg::ExportedNestEgg =
412                        if entry.extension().and_then(|s| s.to_str()) == Some("json") {
413                            match serde_json::from_str(file_content.trim()) {
414                                Ok(egg) => egg,
415                                Err(_) => continue,
416                            }
417                        } else {
418                            match serde_norway::from_str(file_content.trim()) {
419                                Ok(egg) => egg,
420                                Err(_) => continue,
421                            }
422                        };
423
424                    let mut readme = None;
425                    let mut current_path = entry.parent();
426                    'readme: while let Some(path) = current_path {
427                        let mut dir = filesystem.read_dir(path)?;
428
429                        while let Some(Ok((is_dir, entry))) = dir.next_entry() {
430                            if is_dir {
431                                continue;
432                            }
433
434                            let path = path.join(&entry);
435
436                            if entry.to_lowercase().contains("readme")
437                                && filesystem
438                                    .metadata(&path)
439                                    .is_ok_and(|m| m.is_file() && m.len() <= 1024 * 1024)
440                                && let Ok(content) = filesystem.read_to_string(&path)
441                            {
442                                readme = Some(content);
443                                break 'readme;
444                            }
445                        }
446
447                        current_path = path.parent();
448                    }
449
450                    exported_eggs.push(FoundEgg {
451                        path: entry,
452                        readme,
453                        exported_egg,
454                        updated: chrono::DateTime::from_timestamp(
455                            metadata
456                                .modified()
457                                .map_or_else(|_| std::time::SystemTime::now(), |t| t.into_std())
458                                .duration_since(std::time::UNIX_EPOCH)
459                                .unwrap_or_default()
460                                .as_secs() as i64,
461                            0,
462                        )
463                        .unwrap_or_else(chrono::Utc::now),
464                    });
465                }
466
467                drop(prepare_fetch);
468
469                Ok(exported_eggs)
470            })
471            .await??;
472
473        super::egg_repository_egg::EggRepositoryEgg::delete_unused(
474            &state.database,
475            self.uuid,
476            &exported_eggs
477                .iter()
478                .map(|egg| egg.path.to_string_lossy().to_compact_string())
479                .collect::<Vec<_>>(),
480        )
481        .await?;
482
483        let mut futures = Vec::new();
484        futures.reserve_exact(exported_eggs.len());
485
486        for egg in exported_eggs.iter() {
487            futures.push(super::egg_repository_egg::EggRepositoryEgg::create(
488                &state.database,
489                self.uuid,
490                egg.path.to_string_lossy(),
491                egg.readme.as_deref(),
492                &egg.exported_egg,
493                egg.updated.naive_utc(),
494            ));
495        }
496
497        let mut results_stream = futures_util::stream::iter(futures).buffer_unordered(25);
498        while let Some(result) = results_stream.next().await {
499            result?;
500        }
501
502        sqlx::query(
503            r#"
504            UPDATE egg_repositories
505            SET last_synced = NOW()
506            WHERE egg_repositories.uuid = $1
507            "#,
508        )
509        .bind(self.uuid)
510        .execute(state.database.write())
511        .await?;
512
513        Ok(exported_eggs.len())
514    }
515}
516
517#[async_trait::async_trait]
518impl IntoAdminApiObject for EggRepository {
519    type AdminApiObject = AdminApiEggRepository;
520    type ExtraArgs<'a> = ();
521
522    async fn into_admin_api_object<'a>(
523        mut self,
524        state: &crate::State,
525        _args: Self::ExtraArgs<'a>,
526    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
527        let api_object = AdminApiEggRepository::init_hooks(&self, state).await?;
528
529        self.credentials.censor();
530
531        let api_object = finish_extendible!(
532            AdminApiEggRepository {
533                uuid: self.uuid,
534                name: self.name,
535                description: self.description,
536                git_repository: self.git_repository,
537                credentials: self.credentials,
538                last_synced: self.last_synced.map(|dt| dt.and_utc()),
539                created: self.created.and_utc(),
540            },
541            api_object,
542            state
543        )?;
544
545        Ok(api_object)
546    }
547}
548
549#[derive(ToSchema, Deserialize, Validate)]
550pub struct CreateEggRepositoryOptions {
551    #[garde(length(chars, min = 1, max = 255))]
552    #[schema(min_length = 1, max_length = 255)]
553    pub name: compact_str::CompactString,
554    #[garde(length(max = 1024))]
555    #[schema(max_length = 1024)]
556    pub description: Option<compact_str::CompactString>,
557    #[garde(custom(validate_git_repository))]
558    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
559    pub git_repository: compact_str::CompactString,
560    #[garde(dive)]
561    pub credentials: Option<EggRepositoryCredentials>,
562}
563
564#[async_trait::async_trait]
565impl CreatableModel for EggRepository {
566    type CreateOptions<'a> = CreateEggRepositoryOptions;
567    type CreateResult = Self;
568
569    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
570        static CREATE_LISTENERS: LazyLock<CreateListenerList<EggRepository>> =
571            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
572
573        &CREATE_LISTENERS
574    }
575
576    async fn create_with_transaction(
577        state: &crate::State,
578        mut options: Self::CreateOptions<'_>,
579        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
580    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
581        options.validate()?;
582
583        let mut query_builder = InsertQueryBuilder::new("egg_repositories");
584
585        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
586
587        let credentials = options
588            .credentials
589            .get_or_insert(EggRepositoryCredentials::None);
590        credentials.validate_scheme_for_url(&options.git_repository)?;
591        credentials.validate_key_material()?;
592        credentials.encrypt(&state.database).await?;
593
594        query_builder
595            .set("name", &options.name)
596            .set("description", &options.description)
597            .set("git_repository", &options.git_repository)
598            .set("credentials", serde_json::to_value(&credentials)?);
599
600        let row = query_builder
601            .returning(&Self::columns_sql(None))
602            .fetch_one(&mut **transaction)
603            .await?;
604        let mut egg_repository = Self::map(None, &row)?;
605
606        Self::run_after_create_handlers(&mut egg_repository, &options, state, transaction).await?;
607
608        Ok(egg_repository)
609    }
610}
611
612#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
613pub struct UpdateEggRepositoryOptions {
614    #[garde(length(chars, min = 1, max = 255))]
615    #[schema(min_length = 1, max_length = 255)]
616    pub name: Option<compact_str::CompactString>,
617    #[garde(length(max = 1024))]
618    #[schema(max_length = 1024)]
619    #[serde(
620        default,
621        skip_serializing_if = "Option::is_none",
622        with = "::serde_with::rust::double_option"
623    )]
624    pub description: Option<Option<compact_str::CompactString>>,
625    #[garde(inner(custom(validate_git_repository)))]
626    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
627    pub git_repository: Option<compact_str::CompactString>,
628    #[garde(dive)]
629    pub credentials: Option<EggRepositoryCredentials>,
630}
631
632#[async_trait::async_trait]
633impl UpdatableModel for EggRepository {
634    type UpdateOptions = UpdateEggRepositoryOptions;
635
636    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
637        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<EggRepository>> =
638            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
639
640        &UPDATE_LISTENERS
641    }
642
643    async fn update_with_transaction(
644        &mut self,
645        state: &crate::State,
646        mut options: Self::UpdateOptions,
647        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
648    ) -> Result<(), crate::database::DatabaseError> {
649        options.validate()?;
650
651        let mut query_builder = UpdateQueryBuilder::new("egg_repositories");
652
653        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
654            .await?;
655
656        let git_repository = options
657            .git_repository
658            .as_deref()
659            .unwrap_or(&self.git_repository);
660
661        match &mut options.credentials {
662            Some(credentials) => {
663                credentials.validate_scheme_for_url(git_repository)?;
664                credentials.validate_key_material()?;
665                credentials.encrypt(&state.database).await?;
666            }
667            None => self.credentials.validate_scheme_for_url(git_repository)?,
668        }
669
670        query_builder
671            .set("name", options.name.as_ref())
672            .set(
673                "description",
674                options.description.as_ref().map(|d| d.as_ref()),
675            )
676            .set("git_repository", options.git_repository.as_ref())
677            .set(
678                "credentials",
679                options
680                    .credentials
681                    .as_ref()
682                    .map(serde_json::to_value)
683                    .transpose()?,
684            )
685            .where_eq("uuid", self.uuid);
686
687        query_builder.execute(&mut **transaction).await?;
688
689        if let Some(name) = options.name {
690            self.name = name;
691        }
692        if let Some(description) = options.description {
693            self.description = description;
694        }
695        if let Some(git_repository) = options.git_repository {
696            self.git_repository = git_repository;
697        }
698        if let Some(credentials) = options.credentials {
699            self.credentials = credentials;
700        }
701
702        self.run_after_update_handlers(state, transaction).await?;
703
704        Ok(())
705    }
706}
707
708#[async_trait::async_trait]
709impl ByUuid for EggRepository {
710    async fn by_uuid(
711        database: &crate::database::Database,
712        uuid: uuid::Uuid,
713    ) -> Result<Self, crate::database::DatabaseError> {
714        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
715            r#"
716            SELECT {}
717            FROM egg_repositories
718            WHERE egg_repositories.uuid = $1
719            "#,
720            Self::columns_sql(None)
721        )))
722        .bind(uuid)
723        .fetch_one(database.read())
724        .await?;
725
726        Self::map(None, &row)
727    }
728
729    async fn by_uuid_with_transaction(
730        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
731        uuid: uuid::Uuid,
732    ) -> Result<Self, crate::database::DatabaseError> {
733        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
734            r#"
735            SELECT {}
736            FROM egg_repositories
737            WHERE egg_repositories.uuid = $1
738            "#,
739            Self::columns_sql(None)
740        )))
741        .bind(uuid)
742        .fetch_one(&mut **transaction)
743        .await?;
744
745        Self::map(None, &row)
746    }
747}
748
749#[async_trait::async_trait]
750impl DeletableModel for EggRepository {
751    type DeleteOptions = ();
752
753    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
754        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<EggRepository>> =
755            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
756
757        &DELETE_LISTENERS
758    }
759
760    async fn delete_with_transaction(
761        &self,
762        state: &crate::State,
763        options: Self::DeleteOptions,
764        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
765    ) -> Result<(), anyhow::Error> {
766        self.run_delete_handlers(&options, state, transaction)
767            .await?;
768
769        sqlx::query(
770            r#"
771            DELETE FROM egg_repositories
772            WHERE egg_repositories.uuid = $1
773            "#,
774        )
775        .bind(self.uuid)
776        .execute(&mut **transaction)
777        .await?;
778
779        self.run_after_delete_handlers(&options, state, transaction)
780            .await?;
781
782        Ok(())
783    }
784}
785
786#[schema_extension_derive::extendible]
787#[init_args(EggRepository, crate::State)]
788#[hook_args(crate::State)]
789#[derive(ToSchema, Serialize)]
790#[schema(title = "EggRepository")]
791pub struct AdminApiEggRepository {
792    pub uuid: uuid::Uuid,
793
794    pub name: compact_str::CompactString,
795    pub description: Option<compact_str::CompactString>,
796    pub git_repository: compact_str::CompactString,
797    pub credentials: EggRepositoryCredentials,
798
799    pub last_synced: Option<chrono::DateTime<chrono::Utc>>,
800    pub created: chrono::DateTime<chrono::Utc>,
801}