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 {
150150 object_format: "sha1".to_string(),
151151 issues_enabled: true,
152152 pulls_enabled: true,
153+ is_mirror: false,
153154 next_iid: 1,
154155 archived_at: None,
155156 size_bytes: 0,
crates/model/src/entity.rs +2 −0
@@ -425,6 +425,8 @@ pub struct Repo {
425425 pub issues_enabled: bool,
426426 /// Whether pull requests are enabled for this repository.
427427 pub pulls_enabled: bool,
428+ /// Whether this repository is a mirror (kept in sync from a remote).
429+ pub is_mirror: bool,
428430 /// The next issue/PR number to allocate (shared counter).
429431 pub next_iid: Timestamp,
430432 /// 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
1313 /// The columns of `repos` in a fixed order, shared by every `SELECT`.
1414 const 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, \
16- size_bytes, pushed_at, created_at, updated_at";
15+ default_branch, object_format, issues_enabled, pulls_enabled, is_mirror, next_iid, \
16+ archived_at, size_bytes, pushed_at, created_at, updated_at";
1717
1818 /// Fields needed to create a repository. The server fills in the id, timestamps,
1919 /// and the derived size/push bookkeeping.
@@ -69,6 +69,7 @@ impl Store {
6969 object_format: "sha1".to_string(),
7070 issues_enabled: true,
7171 pulls_enabled: true,
72+ is_mirror: false,
7273 next_iid: 1,
7374 archived_at: None,
7475 size_bytes: 0,
@@ -288,6 +289,23 @@ impl Store {
288289 Ok(updated > 0)
289290 }
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+
291309 /// Archive or unarchive a repository. Archiving stamps `archived_at`;
292310 /// unarchiving clears it. Returns `true` if the row existed and was updated.
293311 ///
@@ -448,6 +466,7 @@ impl Store {
448466 object_format: row.try_get("object_format")?,
449467 issues_enabled: self.get_bool(row, "issues_enabled")?,
450468 pulls_enabled: self.get_bool(row, "pulls_enabled")?,
469+ is_mirror: self.get_bool(row, "is_mirror")?,
451470 next_iid: row.try_get("next_iid")?,
452471 archived_at: row.try_get("archived_at")?,
453472 size_bytes: row.try_get("size_bytes")?,
crates/web/src/icons.rs +5 −0
@@ -39,6 +39,7 @@ pub enum Icon {
3939 Scale,
4040 Globe,
4141 Download,
42+ Mirror,
4243 Github,
4344 Mastodon,
4445 }
@@ -118,6 +119,10 @@ impl Icon {
118119 Icon::Download => {
119120 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"/>"#
120121 }
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+ }
121126 Icon::Github => {
122127 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"/>"#
123128 }
crates/web/src/pages.rs +10 −0
@@ -598,6 +598,16 @@ async fn do_migrate(state: &AppState, user: &User, form: &MigrateForm) -> Result
598598 sync_on_push: false,
599599 })
600600 .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;
601611 }
602612 Ok(path)
603613 }
crates/web/src/repo.rs +6 −1
@@ -2354,6 +2354,8 @@ pub(crate) struct RepoEntry {
23542354 pub(crate) updated: i64,
23552355 /// Whether the repo is archived (shows an archived badge).
23562356 pub(crate) archived: bool,
2357+ /// Whether the repo is a mirror (shows the mirror icon).
2358+ pub(crate) is_mirror: bool,
23572359 /// Optional one-line description, shown under the name when present.
23582360 pub(crate) description: Option<String>,
23592361 }
@@ -2368,6 +2370,7 @@ impl RepoEntry {
23682370 default_branch: repo.default_branch.clone(),
23692371 updated: repo.pushed_at.unwrap_or(repo.updated_at),
23702372 archived: repo.archived_at.is_some(),
2373+ is_mirror: repo.is_mirror,
23712374 description: repo.description.clone(),
23722375 }
23732376 }
@@ -2382,6 +2385,7 @@ impl RepoEntry {
23822385 default_branch: repo.default_branch.clone(),
23832386 updated: repo.pushed_at.unwrap_or(repo.updated_at),
23842387 archived: repo.archived_at.is_some(),
2388+ is_mirror: repo.is_mirror,
23852389 description: repo.description.clone(),
23862390 }
23872391 }
@@ -2421,7 +2425,8 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
24212425 li {
24222426 a class="repo-row" href=(e.href) {
24232427 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) }
24252430 (visibility_badge(e.visibility))
24262431 (archived_badge(e.archived))
24272432 }
migrations/postgres/0015_repo_mirror.sql +1 −0
@@ -0,0 +1 @@
1+ALTER TABLE repos ADD COLUMN is_mirror BOOLEAN NOT NULL DEFAULT FALSE;
migrations/sqlite/0015_repo_mirror.sql +1 −0
@@ -0,0 +1 @@
1+ALTER TABLE repos ADD COLUMN is_mirror INTEGER NOT NULL DEFAULT 0;