| 1 | // This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 4 | |
| 5 | //! In-memory representations of the persisted entities. |
| 6 | //! |
| 7 | //! These are plain data types with no behaviour and no I/O — [`crate::store`] |
| 8 | //! (the `store` crate) is responsible for reading and writing them. Timestamps |
| 9 | //! are Unix milliseconds UTC ([`Timestamp`]); identifiers are ULID strings. |
| 10 | //! |
| 11 | //! Storage-only columns (the normalized `*_lower` uniqueness keys) are |
| 12 | //! deliberately absent: they are an implementation detail of the schema, not |
| 13 | //! part of the domain. |
| 14 | |
| 15 | /// A Unix timestamp in **milliseconds** UTC, matching the `BIGINT` columns in |
| 16 | /// the schema. |
| 17 | pub type Timestamp = i64; |
| 18 | |
| 19 | /// The kind of a registered public key: an SSH key (used for both push auth and |
| 20 | /// signature verification) or a GPG key (signature verification only). Stored as |
| 21 | /// the `keys.kind` text `'ssh'` / `'gpg'`. |
| 22 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 23 | pub enum KeyKind { |
| 24 | /// An `OpenSSH` public key. |
| 25 | Ssh, |
| 26 | /// An `OpenPGP` (GPG) public key. |
| 27 | Gpg, |
| 28 | } |
| 29 | |
| 30 | impl KeyKind { |
| 31 | /// The lowercase database token (`"ssh"` / `"gpg"`). |
| 32 | #[must_use] |
| 33 | pub fn as_str(self) -> &'static str { |
| 34 | match self { |
| 35 | Self::Ssh => "ssh", |
| 36 | Self::Gpg => "gpg", |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | /// Parse from the database token, case-insensitively. |
| 41 | #[must_use] |
| 42 | pub fn from_token(s: &str) -> Option<Self> { |
| 43 | match s.to_ascii_lowercase().as_str() { |
| 44 | "ssh" => Some(Self::Ssh), |
| 45 | "gpg" => Some(Self::Gpg), |
| 46 | _ => None, |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | impl std::fmt::Display for KeyKind { |
| 52 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 53 | f.write_str(self.as_str()) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /// A repository's visibility, GitLab-style. Stored as the `repos.visibility` |
| 58 | /// text token. |
| 59 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 60 | pub enum Visibility { |
| 61 | /// Visible to everyone, anonymous included (subject to `allow_anonymous`). |
| 62 | Public, |
| 63 | /// Visible to any signed-in user, but not to anonymous visitors. |
| 64 | Internal, |
| 65 | /// Visible only to the owner, collaborators, and site admins. |
| 66 | #[default] |
| 67 | Private, |
| 68 | } |
| 69 | |
| 70 | impl Visibility { |
| 71 | /// The lowercase database/wire token. |
| 72 | #[must_use] |
| 73 | pub fn as_str(self) -> &'static str { |
| 74 | match self { |
| 75 | Self::Public => "public", |
| 76 | Self::Internal => "internal", |
| 77 | Self::Private => "private", |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// Parse from the token, case-insensitively. |
| 82 | #[must_use] |
| 83 | pub fn from_token(s: &str) -> Option<Self> { |
| 84 | match s.to_ascii_lowercase().as_str() { |
| 85 | "public" => Some(Self::Public), |
| 86 | "internal" => Some(Self::Internal), |
| 87 | "private" => Some(Self::Private), |
| 88 | _ => None, |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /// Whether the repository is private (only owner/collaborators/admins). |
| 93 | #[must_use] |
| 94 | pub fn is_private(self) -> bool { |
| 95 | matches!(self, Self::Private) |
| 96 | } |
| 97 | |
| 98 | /// A rank where higher is more visible (`private` < `internal` < `public`). |
| 99 | /// Used to cap a fork's visibility at its parent's. |
| 100 | #[must_use] |
| 101 | pub fn rank(self) -> u8 { |
| 102 | match self { |
| 103 | Self::Private => 0, |
| 104 | Self::Internal => 1, |
| 105 | Self::Public => 2, |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | impl std::fmt::Display for Visibility { |
| 111 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 112 | f.write_str(self.as_str()) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// One of a user's email addresses. Exactly one per user is primary; the primary |
| 117 | /// is mirrored into [`User::email`]. |
| 118 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 119 | pub struct UserEmail { |
| 120 | /// ULID primary key. |
| 121 | pub id: String, |
| 122 | /// Owning user. |
| 123 | pub user_id: String, |
| 124 | /// The address as entered. |
| 125 | pub email: String, |
| 126 | /// Whether this is the user's primary address. |
| 127 | pub is_primary: bool, |
| 128 | /// When the address was verified, or `None` if still unverified. |
| 129 | pub verified_at: Option<Timestamp>, |
| 130 | /// Creation time. |
| 131 | pub created_at: Timestamp, |
| 132 | } |
| 133 | |
| 134 | impl UserEmail { |
| 135 | /// Whether the address has been verified. |
| 136 | #[must_use] |
| 137 | pub fn verified(&self) -> bool { |
| 138 | self.verified_at.is_some() |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | /// A registered account. |
| 143 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 144 | pub struct User { |
| 145 | /// ULID primary key. |
| 146 | pub id: String, |
| 147 | /// The name as the user typed it (case preserved for display). |
| 148 | pub username: String, |
| 149 | /// The primary email address (denormalized from [`UserEmail`] for quick |
| 150 | /// access and case-insensitive login/uniqueness). |
| 151 | pub email: String, |
| 152 | /// Whether the primary email is shown on the public profile. |
| 153 | pub email_public: bool, |
| 154 | /// Optional human-friendly display name. |
| 155 | pub display_name: Option<String>, |
| 156 | /// Optional pronouns, shown on the profile (e.g. `they/them`). |
| 157 | pub pronouns: Option<String>, |
| 158 | /// Optional free-text bio shown on the profile. |
| 159 | pub bio: Option<String>, |
| 160 | /// Optional location string. |
| 161 | pub location: Option<String>, |
| 162 | /// Up to five arbitrary profile links (their brand icons are inferred from |
| 163 | /// the URL host). |
| 164 | pub links: Vec<String>, |
| 165 | /// Content type of the uploaded avatar, or `None` to use a generated |
| 166 | /// identicon. The bytes live on disk, not in this row. |
| 167 | pub avatar_mime: Option<String>, |
| 168 | /// Argon2id PHC hash, or `None` until an invite is completed. |
| 169 | pub password_hash: Option<String>, |
| 170 | /// Whether the user must set a new password on next login. |
| 171 | pub must_change_password: bool, |
| 172 | /// Whether the user is a site administrator. |
| 173 | pub is_admin: bool, |
| 174 | /// When the account was disabled, if it is. |
| 175 | pub disabled_at: Option<Timestamp>, |
| 176 | /// Creation time. |
| 177 | pub created_at: Timestamp, |
| 178 | /// Last modification time. |
| 179 | pub updated_at: Timestamp, |
| 180 | } |
| 181 | |
| 182 | /// A pending activation or password-reset invite. The emailed token is never |
| 183 | /// stored; `token_hash` is its SHA-256, matched on redemption. |
| 184 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 185 | pub struct Invite { |
| 186 | /// ULID primary key. |
| 187 | pub id: String, |
| 188 | /// The user this invite activates. |
| 189 | pub user_id: String, |
| 190 | /// SHA-256 (hex) of the emailed token. |
| 191 | pub token_hash: String, |
| 192 | /// `"activate"` or `"reset"`. |
| 193 | pub purpose: String, |
| 194 | /// When the invite expires. |
| 195 | pub expires_at: Timestamp, |
| 196 | /// When it was redeemed, if it has been (single-use). |
| 197 | pub used_at: Option<Timestamp>, |
| 198 | /// Creation time. |
| 199 | pub created_at: Timestamp, |
| 200 | } |
| 201 | |
| 202 | /// A nested, user-owned group. Groups provide the repository hierarchy; `path` is |
| 203 | /// the materialized `group/sub/leaf` under the owner. |
| 204 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 205 | pub struct Group { |
| 206 | /// ULID primary key. |
| 207 | pub id: String, |
| 208 | /// Owning user. |
| 209 | pub owner_id: String, |
| 210 | /// Parent group, or `None` for a top-level group. |
| 211 | pub parent_id: Option<String>, |
| 212 | /// The group's own name (the final path segment). |
| 213 | pub name: String, |
| 214 | /// Materialized full path within the owner, e.g. `group/sub/leaf`. |
| 215 | pub path: String, |
| 216 | /// Optional description. |
| 217 | pub description: Option<String>, |
| 218 | /// Whether the group was created explicitly (persists) rather than |
| 219 | /// auto-created to hold a repo (garbage-collected when empty). |
| 220 | pub manual: bool, |
| 221 | /// The group's visibility (gates its listing page). |
| 222 | pub visibility: Visibility, |
| 223 | /// Creation time. |
| 224 | pub created_at: Timestamp, |
| 225 | } |
| 226 | |
| 227 | /// A registered public key, for SSH push authentication and/or signature |
| 228 | /// verification. |
| 229 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 230 | pub struct Key { |
| 231 | /// ULID primary key. |
| 232 | pub id: String, |
| 233 | /// Owning user. |
| 234 | pub user_id: String, |
| 235 | /// SSH or GPG. |
| 236 | pub kind: KeyKind, |
| 237 | /// Optional label. |
| 238 | pub name: Option<String>, |
| 239 | /// `SHA256:…` for SSH, full hex for GPG. |
| 240 | pub fingerprint: String, |
| 241 | /// The `authorized_keys` line (SSH) or ASCII-armored block (GPG). |
| 242 | pub public_key: String, |
| 243 | /// Stable 1-based index within `(user, kind)`, used by `key del`. Deleting one |
| 244 | /// key never renumbers the others. |
| 245 | pub ordinal: i64, |
| 246 | /// When the key was last used to authenticate, if ever. |
| 247 | pub last_used_at: Option<Timestamp>, |
| 248 | /// When ownership of the key was verified, or `None` if unverified. SSH keys |
| 249 | /// verify on first successful SSH auth; GPG keys via a signed challenge. |
| 250 | pub verified_at: Option<Timestamp>, |
| 251 | /// Creation time. |
| 252 | pub created_at: Timestamp, |
| 253 | } |
| 254 | |
| 255 | impl Key { |
| 256 | /// Whether ownership of the key has been verified. |
| 257 | #[must_use] |
| 258 | pub fn verified(&self) -> bool { |
| 259 | self.verified_at.is_some() |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | /// An API token record. The JWT itself is never stored; this row exists so a token |
| 264 | /// can be listed and revoked (`revoked_at`), which the API checks on every request. |
| 265 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 266 | pub struct ApiToken { |
| 267 | /// ULID primary key, and the JWT `jti`. |
| 268 | pub id: String, |
| 269 | /// Owning user. |
| 270 | pub user_id: String, |
| 271 | /// Human label. |
| 272 | pub name: String, |
| 273 | /// Comma-separated scope list. |
| 274 | pub scopes: String, |
| 275 | /// Expiry, or `None` for a non-expiring token. |
| 276 | pub expires_at: Option<Timestamp>, |
| 277 | /// When the token was revoked, if it has been. |
| 278 | pub revoked_at: Option<Timestamp>, |
| 279 | /// When the token was last used, if ever. |
| 280 | pub last_used_at: Option<Timestamp>, |
| 281 | /// Creation time. |
| 282 | pub created_at: Timestamp, |
| 283 | } |
| 284 | |
| 285 | /// A per-repo issue/PR label, optionally **scoped** (`kind: value`, e.g. |
| 286 | /// `type: backend`). Scoped labels are mutually exclusive within their scope. |
| 287 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 288 | pub struct Label { |
| 289 | /// ULID primary key. |
| 290 | pub id: String, |
| 291 | /// Owning repository. |
| 292 | pub repo_id: String, |
| 293 | /// The label name, e.g. `bug` or `type: backend`. |
| 294 | pub name: String, |
| 295 | /// Hex colour (`#rrggbb`). |
| 296 | pub color: String, |
| 297 | /// Optional description. |
| 298 | pub description: Option<String>, |
| 299 | /// Creation time. |
| 300 | pub created_at: Timestamp, |
| 301 | } |
| 302 | |
| 303 | impl Label { |
| 304 | /// The label's scope (`kind`) when it is `kind: value`, else `None`. |
| 305 | #[must_use] |
| 306 | pub fn scope(&self) -> Option<&str> { |
| 307 | self.name.split_once(": ").map(|(scope, _)| scope.trim()) |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | /// Whether an issue/PR is open or closed. |
| 312 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 313 | pub enum IssueState { |
| 314 | /// Open. |
| 315 | Open, |
| 316 | /// Closed. |
| 317 | Closed, |
| 318 | } |
| 319 | |
| 320 | impl IssueState { |
| 321 | /// The database token (`open` / `closed`). |
| 322 | #[must_use] |
| 323 | pub fn as_str(self) -> &'static str { |
| 324 | match self { |
| 325 | Self::Open => "open", |
| 326 | Self::Closed => "closed", |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | /// Parse from the database token. |
| 331 | #[must_use] |
| 332 | pub fn from_token(s: &str) -> Self { |
| 333 | if s == "closed" { |
| 334 | Self::Closed |
| 335 | } else { |
| 336 | Self::Open |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | /// An issue or (when `is_pull`) a pull request. Both share the `issues` table and |
| 342 | /// the per-repo number counter, so `#N` is unique across both. |
| 343 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 344 | pub struct Issue { |
| 345 | /// ULID primary key. |
| 346 | pub id: String, |
| 347 | /// Owning repository. |
| 348 | pub repo_id: String, |
| 349 | /// The per-repo number shown as `#N`. |
| 350 | pub number: Timestamp, |
| 351 | /// The author. |
| 352 | pub author_id: String, |
| 353 | /// Title. |
| 354 | pub title: String, |
| 355 | /// Markdown body. |
| 356 | pub body: String, |
| 357 | /// Open or closed. |
| 358 | pub state: IssueState, |
| 359 | /// Whether this row is a pull request. |
| 360 | pub is_pull: bool, |
| 361 | /// The assignee, if any. |
| 362 | pub assignee_id: Option<String>, |
| 363 | /// Creation time. |
| 364 | pub created_at: Timestamp, |
| 365 | /// Last update time. |
| 366 | pub updated_at: Timestamp, |
| 367 | /// When it was closed, if it is. |
| 368 | pub closed_at: Option<Timestamp>, |
| 369 | /// When the conversation was locked, or `None` if unlocked. |
| 370 | pub locked_at: Option<Timestamp>, |
| 371 | } |
| 372 | |
| 373 | impl Issue { |
| 374 | /// Whether the conversation is locked (only writers may comment). |
| 375 | #[must_use] |
| 376 | pub fn locked(&self) -> bool { |
| 377 | self.locked_at.is_some() |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | /// A comment on an issue or pull request. |
| 382 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 383 | pub struct Comment { |
| 384 | /// ULID primary key. |
| 385 | pub id: String, |
| 386 | /// The issue/PR this comment belongs to. |
| 387 | pub issue_id: String, |
| 388 | /// The author. |
| 389 | pub author_id: String, |
| 390 | /// Markdown body. |
| 391 | pub body: String, |
| 392 | /// Creation time. |
| 393 | pub created_at: Timestamp, |
| 394 | /// Last edit time. |
| 395 | pub updated_at: Timestamp, |
| 396 | } |
| 397 | |
| 398 | /// The pull-request side table for an `issues` row with `is_pull = 1`. |
| 399 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 400 | pub struct PullRequest { |
| 401 | /// The `issues` row id. |
| 402 | pub issue_id: String, |
| 403 | /// The base ref (merged into). |
| 404 | pub base_ref: String, |
| 405 | /// The head ref (source of the changes). |
| 406 | pub head_ref: String, |
| 407 | /// When it was merged, if it has been. |
| 408 | pub merged_at: Option<Timestamp>, |
| 409 | /// Who merged it. |
| 410 | pub merged_by: Option<String>, |
| 411 | /// The resulting merge commit sha. |
| 412 | pub merge_sha: Option<String>, |
| 413 | /// The strategy used (`merge` | `squash` | `rebase`). |
| 414 | pub merge_strategy: Option<String>, |
| 415 | } |
| 416 | |
| 417 | /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the |
| 418 | /// database is the sole authority mapping `(owner, path)` to this id. |
| 419 | // Several independent feature toggles (issues/pulls/releases/mirror) are flags, |
| 420 | // not a state machine — a struct of bools is the clearest representation. |
| 421 | #[allow(clippy::struct_excessive_bools)] |
| 422 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 423 | pub struct Repo { |
| 424 | /// ULID primary key, and the on-disk directory stem. |
| 425 | pub id: String, |
| 426 | /// Owning user. |
| 427 | pub owner_id: String, |
| 428 | /// Containing group, or `None` when the repo hangs directly off the owner. |
| 429 | pub group_id: Option<String>, |
| 430 | /// The repo's own name (the final path segment). |
| 431 | pub name: String, |
| 432 | /// Materialized full path within the owner, e.g. `group/sub/repo`; equal to |
| 433 | /// `name` when ungrouped. |
| 434 | pub path: String, |
| 435 | /// Optional one-line description. |
| 436 | pub description: Option<String>, |
| 437 | /// Who may see the repository (public / internal / private). |
| 438 | pub visibility: Visibility, |
| 439 | /// The branch shown by default and used for HEAD. |
| 440 | pub default_branch: String, |
| 441 | /// The object hash algorithm (`sha1` or `sha256`), fixed at creation. |
| 442 | pub object_format: String, |
| 443 | /// Whether issues are enabled for this repository. |
| 444 | pub issues_enabled: bool, |
| 445 | /// Whether pull requests are enabled for this repository. |
| 446 | pub pulls_enabled: bool, |
| 447 | /// Whether releases are enabled for this repository. |
| 448 | pub releases_enabled: bool, |
| 449 | /// Whether this repository is a mirror (kept in sync from a remote). |
| 450 | pub is_mirror: bool, |
| 451 | /// The repository this one was forked from, if any. |
| 452 | pub fork_parent_id: Option<String>, |
| 453 | /// The next issue/PR number to allocate (shared counter). |
| 454 | pub next_iid: Timestamp, |
| 455 | /// When the repo was archived (read-only), if it is. |
| 456 | pub archived_at: Option<Timestamp>, |
| 457 | /// Approximate on-disk size in bytes, refreshed by maintenance. |
| 458 | pub size_bytes: i64, |
| 459 | /// Time of the last push, if any. |
| 460 | pub pushed_at: Option<Timestamp>, |
| 461 | /// Creation time. |
| 462 | pub created_at: Timestamp, |
| 463 | /// Last modification time. |
| 464 | pub updated_at: Timestamp, |
| 465 | } |