fabrica

hanna/fabrica

feat: mark mirror repositories with an icon and disable issues/PRs

acd383a · hanna committed on 2026-07-26

Add repos.is_mirror (migration 0015) and set it when a Migrate import
requests a mirror, alongside disabling issues and pull requests (a mirror
is a read-only copy). Repo listings show a mirror (refresh) icon instead
of the box, and the store gains set_mirror.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +47 −3UnifiedSplit
crates/auth/src/access.rs +1 −0
@@ -150,6 +150,7 @@ mod tests {
150 object_format: "sha1".to_string(),150 object_format: "sha1".to_string(),
151 issues_enabled: true,151 issues_enabled: true,
152 pulls_enabled: true,152 pulls_enabled: true,
153 is_mirror: false,
153 next_iid: 1,154 next_iid: 1,
154 archived_at: None,155 archived_at: None,
155 size_bytes: 0,156 size_bytes: 0,
crates/model/src/entity.rs +2 −0
@@ -425,6 +425,8 @@ pub struct Repo {
425 pub issues_enabled: bool,425 pub issues_enabled: bool,
426 /// Whether pull requests are enabled for this repository.426 /// Whether pull requests are enabled for this repository.
427 pub pulls_enabled: bool,427 pub pulls_enabled: bool,
428 /// Whether this repository is a mirror (kept in sync from a remote).
429 pub is_mirror: bool,
428 /// The next issue/PR number to allocate (shared counter).430 /// The next issue/PR number to allocate (shared counter).
429 pub next_iid: Timestamp,431 pub next_iid: Timestamp,
430 /// When the repo was archived (read-only), if it is.432 /// When the repo was archived (read-only), if it is.
crates/store/src/repos.rs +21 −2
@@ -12,8 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
13/// The columns of `repos` in a fixed order, shared by every `SELECT`.13/// The columns of `repos` in a fixed order, shared by every `SELECT`.
14const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \14const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \
15 default_branch, object_format, issues_enabled, pulls_enabled, next_iid, archived_at, \15 default_branch, object_format, issues_enabled, pulls_enabled, is_mirror, next_iid, \
16 size_bytes, pushed_at, created_at, updated_at";16 archived_at, size_bytes, pushed_at, created_at, updated_at";
1717
18/// Fields needed to create a repository. The server fills in the id, timestamps,18/// Fields needed to create a repository. The server fills in the id, timestamps,
19/// and the derived size/push bookkeeping.19/// and the derived size/push bookkeeping.
@@ -69,6 +69,7 @@ impl Store {
69 object_format: "sha1".to_string(),69 object_format: "sha1".to_string(),
70 issues_enabled: true,70 issues_enabled: true,
71 pulls_enabled: true,71 pulls_enabled: true,
72 is_mirror: false,
72 next_iid: 1,73 next_iid: 1,
73 archived_at: None,74 archived_at: None,
74 size_bytes: 0,75 size_bytes: 0,
@@ -288,6 +289,23 @@ impl Store {
288 Ok(updated > 0)289 Ok(updated > 0)
289 }290 }
290291
292 /// Mark a repository as a mirror (or not). Mirror repos are kept in sync from
293 /// a remote and have issues/PRs disabled.
294 ///
295 /// # Errors
296 ///
297 /// Returns [`StoreError::Query`] on a database failure.
298 pub async fn set_mirror(&self, repo_id: &str, is_mirror: bool) -> Result<bool, StoreError> {
299 let updated = sqlx::query("UPDATE repos SET is_mirror = $1, updated_at = $2 WHERE id = $3")
300 .bind(is_mirror)
301 .bind(now_ms())
302 .bind(repo_id)
303 .execute(&self.pool)
304 .await?
305 .rows_affected();
306 Ok(updated > 0)
307 }
308
291 /// Archive or unarchive a repository. Archiving stamps `archived_at`;309 /// Archive or unarchive a repository. Archiving stamps `archived_at`;
292 /// unarchiving clears it. Returns `true` if the row existed and was updated.310 /// unarchiving clears it. Returns `true` if the row existed and was updated.
293 ///311 ///
@@ -448,6 +466,7 @@ impl Store {
448 object_format: row.try_get("object_format")?,466 object_format: row.try_get("object_format")?,
449 issues_enabled: self.get_bool(row, "issues_enabled")?,467 issues_enabled: self.get_bool(row, "issues_enabled")?,
450 pulls_enabled: self.get_bool(row, "pulls_enabled")?,468 pulls_enabled: self.get_bool(row, "pulls_enabled")?,
469 is_mirror: self.get_bool(row, "is_mirror")?,
451 next_iid: row.try_get("next_iid")?,470 next_iid: row.try_get("next_iid")?,
452 archived_at: row.try_get("archived_at")?,471 archived_at: row.try_get("archived_at")?,
453 size_bytes: row.try_get("size_bytes")?,472 size_bytes: row.try_get("size_bytes")?,
crates/web/src/icons.rs +5 −0
@@ -39,6 +39,7 @@ pub enum Icon {
39 Scale,39 Scale,
40 Globe,40 Globe,
41 Download,41 Download,
42 Mirror,
42 Github,43 Github,
43 Mastodon,44 Mastodon,
44}45}
@@ -118,6 +119,10 @@ impl Icon {
118 Icon::Download => {119 Icon::Download => {
119 r#"<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>"#120 r#"<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>"#
120 }121 }
122 // Lucide "refresh-cw": circular arrows, for a mirror (kept in sync).
123 Icon::Mirror => {
124 r#"<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>"#
125 }
121 Icon::Github => {126 Icon::Github => {
122 r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"#127 r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"#
123 }128 }
crates/web/src/pages.rs +10 −0
@@ -598,6 +598,16 @@ async fn do_migrate(state: &AppState, user: &User, form: &MigrateForm) -> Result
598 sync_on_push: false,598 sync_on_push: false,
599 })599 })
600 .await;600 .await;
601 // A mirror is a read-only copy: mark it, and disable issues/PRs.
602 let _ = state.store.set_mirror(&repo.id, true).await;
603 let _ = state
604 .store
605 .set_feature_enabled(&repo.id, "issues", false)
606 .await;
607 let _ = state
608 .store
609 .set_feature_enabled(&repo.id, "pulls", false)
610 .await;
601 }611 }
602 Ok(path)612 Ok(path)
603}613}
crates/web/src/repo.rs +6 −1
@@ -2354,6 +2354,8 @@ pub(crate) struct RepoEntry {
2354 pub(crate) updated: i64,2354 pub(crate) updated: i64,
2355 /// Whether the repo is archived (shows an archived badge).2355 /// Whether the repo is archived (shows an archived badge).
2356 pub(crate) archived: bool,2356 pub(crate) archived: bool,
2357 /// Whether the repo is a mirror (shows the mirror icon).
2358 pub(crate) is_mirror: bool,
2357 /// Optional one-line description, shown under the name when present.2359 /// Optional one-line description, shown under the name when present.
2358 pub(crate) description: Option<String>,2360 pub(crate) description: Option<String>,
2359}2361}
@@ -2368,6 +2370,7 @@ impl RepoEntry {
2368 default_branch: repo.default_branch.clone(),2370 default_branch: repo.default_branch.clone(),
2369 updated: repo.pushed_at.unwrap_or(repo.updated_at),2371 updated: repo.pushed_at.unwrap_or(repo.updated_at),
2370 archived: repo.archived_at.is_some(),2372 archived: repo.archived_at.is_some(),
2373 is_mirror: repo.is_mirror,
2371 description: repo.description.clone(),2374 description: repo.description.clone(),
2372 }2375 }
2373 }2376 }
@@ -2382,6 +2385,7 @@ impl RepoEntry {
2382 default_branch: repo.default_branch.clone(),2385 default_branch: repo.default_branch.clone(),
2383 updated: repo.pushed_at.unwrap_or(repo.updated_at),2386 updated: repo.pushed_at.unwrap_or(repo.updated_at),
2384 archived: repo.archived_at.is_some(),2387 archived: repo.archived_at.is_some(),
2388 is_mirror: repo.is_mirror,
2385 description: repo.description.clone(),2389 description: repo.description.clone(),
2386 }2390 }
2387 }2391 }
@@ -2421,7 +2425,8 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
2421 li {2425 li {
2422 a class="repo-row" href=(e.href) {2426 a class="repo-row" href=(e.href) {
2423 span class="repo-row-name" {2427 span class="repo-row-name" {
2424 (icon(Icon::Box)) span { (e.label) }2428 (icon(if e.is_mirror { Icon::Mirror } else { Icon::Box }))
2429 span { (e.label) }
2425 (visibility_badge(e.visibility))2430 (visibility_badge(e.visibility))
2426 (archived_badge(e.archived))2431 (archived_badge(e.archived))
2427 }2432 }
migrations/postgres/0015_repo_mirror.sql +1 −0
@@ -0,0 +1 @@
1ALTER TABLE repos ADD COLUMN is_mirror BOOLEAN NOT NULL DEFAULT FALSE;
migrations/sqlite/0015_repo_mirror.sql +1 −0
@@ -0,0 +1 @@
1ALTER TABLE repos ADD COLUMN is_mirror INTEGER NOT NULL DEFAULT 0;