main
rb 250 lines 8.83 KB
Raw
1 class Repository < ApplicationRecord
2 KINDS = %w[code model].freeze
3
4 belongs_to :user
5 has_many :stars, dependent: :destroy
6 has_many :stargazers, through: :stars, source: :user
7 has_many :pull_requests, dependent: :destroy
8 has_many :issues, dependent: :destroy
9 has_many :repository_imports, dependent: :nullify
10
11 # Upstream credential for a private mirror, encrypted at rest and never
12 # returned to the client. Nil for public upstreams and non-mirror repos.
13 encrypts :upstream_token
14
15 scope :mirrors, -> { where(mirror: true) }
16
17 scope :models, -> { where(kind: "model") }
18 scope :code, -> { where(kind: "code") }
19 scope :visible_to, ->(user) {
20 user ? where("is_private = ? OR user_id = ?", false, user.id) : where(is_private: false)
21 }
22
23 validates :name, presence: true,
24 format: {
25 with: /\A(?!\.)[a-zA-Z0-9_.\-]+(?<!\.)(?<!\.\.)\z/,
26 message: "may only contain letters, numbers, hyphens, underscores, and dots; must not start or end with a dot; must not contain consecutive dots"
27 },
28 uniqueness: { scope: :user_id, case_sensitive: false }
29 validates :default_branch, presence: true
30 validates :kind, inclusion: { in: KINDS }
31
32 validate :no_consecutive_dots_in_name
33 validate :no_leading_hyphen_in_name
34
35 def to_param
36 name
37 end
38
39 def full_name
40 "#{user.username}/#{name}"
41 end
42
43 # Absolute web URL for this repository, used when building links for API/MCP
44 # clients that have no request context of their own.
45 def self.site_url
46 ENV.fetch("SIGITSI_URL", "https://sigit.si")
47 end
48
49 def html_url
50 "#{self.class.site_url}/#{full_name}"
51 end
52
53 # True if +user+ may push to / open changes against this repo. siGit repos
54 # have a single owner and no collaborators yet, so write == ownership — but a
55 # mirror is read-only for everyone (including the owner) until it's detached,
56 # so its upstream stays the single source of truth.
57 def writable_by?(user)
58 return false if mirror?
59 user.present? && user_id == user.id
60 end
61
62 # ── Mirror mode ─────────────────────────────────────────────────────────
63
64 def mirror?
65 mirror
66 end
67
68 # Flip a mirror into a normal writable repo and stop syncing. Idempotent: a
69 # non-mirror repo is left untouched. The upstream URL is kept for reference,
70 # but the credential is dropped since we no longer fetch with it.
71 def detach_mirror!
72 return false unless mirror?
73
74 update!(
75 mirror: false,
76 upstream_token: nil,
77 mirror_status: nil,
78 mirror_error: nil
79 )
80 true
81 end
82
83 # A short human status for the mirror badge/health line.
84 def mirror_state_label
85 return nil unless mirror?
86
87 case mirror_status
88 when "syncing" then "Syncing…"
89 when "failed" then "Sync failed"
90 else
91 mirror_synced_at ? "Synced #{mirror_synced_at.strftime('%b %-d, %H:%M UTC')}" : "Awaiting first sync"
92 end
93 end
94
95 # Read a file's contents at +ref+ (branch, tag, or SHA). Returns the content
96 # String, or nil when the path doesn't exist at that ref. This is the git read
97 # layer the MCP `get_file_contents` tool sits on.
98 def blob_at(ref, path)
99 GitRepositoryService.file_content(disk_path, ref, path)
100 end
101
102 # Search file contents at +ref+ (default branch when omitted). Returns an array
103 # of { path:, line:, snippet: } hashes, capped at +limit+.
104 def search_code(query, limit: 20, ref: nil)
105 GitRepositoryService.search_code(disk_path, ref || default_branch, query, limit: limit)
106 end
107
108 # Unified diff of +head+ against its merge base with +base+ (what a pull
109 # request shows). Returns the diff String ("" when the refs are identical),
110 # or nil when either ref is unknown — e.g. the head branch of a merged pull
111 # request has been deleted.
112 def diff_between(base, head)
113 GitRepositoryService.diff(disk_path, base, head)
114 end
115
116 # Next number in the shared issue / pull request number space. Issues and
117 # pull requests draw from one per-repository sequence (as on GitHub), so a
118 # number resolves to exactly one record. Call inside a `with_lock` block
119 # when allocating.
120 def next_issue_number
121 [ pull_requests.maximum(:number) || 0, issues.maximum(:number) || 0 ].max + 1
122 end
123
124 def git_path
125 disk_path
126 end
127
128 def open_pull_requests_count
129 pull_requests.open.count
130 end
131
132 def initialized?
133 return false if git_path.blank?
134 Dir.exist?(git_path) && File.exist?(File.join(git_path, "HEAD"))
135 end
136
137 def model?
138 kind == "model"
139 end
140
141 def code?
142 kind != "model"
143 end
144
145 def starred_by?(user)
146 return false unless user
147 stars.exists?(user_id: user.id)
148 end
149
150 # ── SEO / social-card metadata ──────────────────────────────────────────
151 #
152 # Common source-file extensions → { name, color }. Colors mirror GitHub
153 # Linguist so the language dot reads as expected. Intentionally small: the
154 # goal is a good guess for the share card, not exhaustive coverage.
155 LANGUAGE_BY_EXT = {
156 "rb" => [ "Ruby", "#701516" ], "py" => [ "Python", "#3572A5" ],
157 "js" => [ "JavaScript", "#f1e05a" ], "mjs" => [ "JavaScript", "#f1e05a" ],
158 "ts" => [ "TypeScript", "#3178c6" ], "tsx" => [ "TypeScript", "#3178c6" ],
159 "jsx" => [ "JavaScript", "#f1e05a" ], "go" => [ "Go", "#00ADD8" ],
160 "rs" => [ "Rust", "#dea584" ], "java" => [ "Java", "#b07219" ],
161 "kt" => [ "Kotlin", "#A97BFF" ], "swift" => [ "Swift", "#F05138" ],
162 "c" => [ "C", "#555555" ], "h" => [ "C", "#555555" ],
163 "cpp" => [ "C++", "#f34b7d" ], "cc" => [ "C++", "#f34b7d" ], "hpp" => [ "C++", "#f34b7d" ],
164 "cs" => [ "C#", "#178600" ], "php" => [ "PHP", "#4F5D95" ],
165 "rb.erb" => [ "Ruby", "#701516" ], "erb" => [ "HTML", "#e34c26" ],
166 "html" => [ "HTML", "#e34c26" ], "css" => [ "CSS", "#563d7c" ],
167 "scss" => [ "SCSS", "#c6538c" ], "vue" => [ "Vue", "#41b883" ],
168 "ex" => [ "Elixir", "#6e4a7e" ], "exs" => [ "Elixir", "#6e4a7e" ],
169 "scala" => [ "Scala", "#c22d40" ], "sh" => [ "Shell", "#89e051" ],
170 "lua" => [ "Lua", "#000080" ], "dart" => [ "Dart", "#00B4AB" ],
171 "r" => [ "R", "#198CE7" ], "jl" => [ "Julia", "#a270ba" ],
172 "ipynb" => [ "Jupyter Notebook", "#DA5B0B" ], "sql" => [ "SQL", "#e38c00" ],
173 "md" => [ "Markdown", "#083fa1" ]
174 }.freeze
175
176 WEIGHT_EXTS = %w[safetensors gguf bin onnx pt pth h5 ckpt].freeze
177
178 # A short, human description for titles/social cards, with sane fallbacks so
179 # tags are never empty even when the repo has no description.
180 def seo_description
181 return description.strip if description.present?
182
183 if model?
184 "#{name} — an open-weights model by @#{user.username}, with a full model " \
185 "card and LFS-backed weights on siGit."
186 else
187 "#{name} — a Git repository by @#{user.username} on siGit, Git hosting " \
188 "built for AI workflows."
189 end
190 end
191
192 # Best-guess primary language as [name, color], or nil. For model repos that
193 # carry weight files we surface the weight format instead of a code language.
194 # Memoized; walks the tree once on first call.
195 def primary_language
196 return @primary_language if defined?(@primary_language)
197 @primary_language = detect_primary_language
198 end
199
200 # Opaque content version for the repo's social card — changes whenever the
201 # tip commit, name, description, or star count changes, so cached cards stay
202 # correct without manual busting.
203 def og_version
204 head = initialized? ? GitRepositoryService.head_sha(disk_path, default_branch) : nil
205 Digest::SHA256.hexdigest(
206 [ id, name, description, stars_count, head, updated_at.to_i ].join("|")
207 )[0, 16]
208 end
209
210 # Parses the Hugging Face-style YAML front-matter from the README (the "model
211 # card") on the default branch. Memoized per instance; returns a ModelCard
212 # even when there is no front-matter so callers can use it unconditionally.
213 def model_card(branch: nil)
214 @model_card ||= begin
215 branch ||= default_branch
216 readme = (GitRepositoryService.readme_content(disk_path, branch) if initialized?)
217 ModelCard.parse(readme&.dig(:content))
218 end
219 end
220
221 private
222
223 def detect_primary_language
224 return nil unless initialized?
225 files = GitRepositoryService.tree_filenames(disk_path, default_branch)
226 return nil if files.empty?
227
228 counts = Hash.new(0)
229 files.each do |f|
230 ext = File.extname(f).delete_prefix(".").downcase
231 next if ext.empty?
232 if WEIGHT_EXTS.include?(ext)
233 counts[[ "#{ext.upcase} weights", "#92D3A2" ]] += 1
234 elsif (lang = LANGUAGE_BY_EXT[ext])
235 counts[lang] += 1
236 end
237 end
238 counts.max_by { |_lang, n| n }&.first
239 end
240
241 def no_consecutive_dots_in_name
242 return if name.blank?
243 errors.add(:name, "must not contain consecutive dots") if name.include?("..")
244 end
245
246 def no_leading_hyphen_in_name
247 return if name.blank?
248 errors.add(:name, "must not start with a hyphen") if name.start_with?("-")
249 end
250 end