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