rin
Публичный Open-source lightweight git hosting.
repo.go · 649 строк · 18.8 KB
Raw
1 package repo
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "time"
13
14 "polar/internal/db"
15 "polar/internal/git"
16 "polar/internal/user"
17 )
18
19 var (
20 ErrRepoNotFound = errors.New("repository not found")
21 ErrRepoAlreadyExists = errors.New("repository with this name already exists")
22 ErrInvalidRepoName = errors.New("repository name must be 1–64 characters (letters, digits, '.', '-', '_')")
23 ErrPathTraversal = errors.New("unsafe repository path")
24 )
25
26 var repoNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
27
28 var reservedRepoNames = map[string]struct{}{
29 "settings": {}, "commits": {}, "commit": {}, "tree": {}, "blob": {},
30 "raw": {}, "info": {}, "git-upload-pack": {}, "git-receive-pack": {},
31 "new": {}, "delete": {}, "rename": {}, "branches": {}, "tags": {},
32 "issues": {}, "archive": {}, "pulls": {}, "releases": {}, "activity": {},
33 }
34
35 type Repository struct {
36 ID int64
37 OwnerID int64
38 OwnerUsername string
39 OwnerAvatarURL string
40 Name string
41 Description string
42 AvatarURL string
43 BackgroundURL string
44 IsPrivate bool
45 DefaultBranch string
46 EnableIssues bool
47 EnablePulls bool
48 EnableReleases bool
49 EnableActivity bool
50
51 ForkedFromRepoID *int64
52 ForkedFromOwner string
53 ForkedFromName string
54 ForkCount int
55
56 OpenIssueCount int
57 OpenPRCount int
58 ReleaseCount int
59 CreatedAt time.Time
60 UpdatedAt time.Time
61 }
62
63 type Service struct {
64 db *db.DB
65 reposDir string
66 }
67
68 func NewService(database *db.DB, reposDir string) *Service {
69 return &Service{
70 db: database,
71 reposDir: reposDir,
72 }
73 }
74
75 func ValidateRepoName(name string) error {
76 name = strings.TrimSpace(name)
77 if !repoNameRegex.MatchString(name) {
78 return ErrInvalidRepoName
79 }
80 lower := strings.ToLower(name)
81 if _, reserved := reservedRepoNames[lower]; reserved {
82 return ErrInvalidRepoName
83 }
84 if strings.Contains(lower, "..") ||
85 strings.HasSuffix(lower, ".git") ||
86 strings.HasSuffix(lower, ".lock") ||
87 strings.HasSuffix(lower, ".") {
88 return ErrInvalidRepoName
89 }
90 return nil
91 }
92
93 func (s *Service) DiskPath(ownerUsername, repoName string) (string, error) {
94 if err := user.ValidateUsername(ownerUsername); err != nil {
95 return "", err
96 }
97 if err := ValidateRepoName(repoName); err != nil {
98 return "", err
99 }
100
101 baseAbs, err := filepath.Abs(s.reposDir)
102 if err != nil {
103 return "", fmt.Errorf("resolve repos dir: %w", err)
104 }
105 target := filepath.Join(baseAbs, strings.ToLower(ownerUsername), strings.ToLower(repoName)+".git")
106 rel, err := filepath.Rel(baseAbs, target)
107 if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
108 return "", ErrPathTraversal
109 }
110 return target, nil
111 }
112
113 func (s *Service) Create(ctx context.Context, owner *user.User, name, description string, isPrivate bool) (*Repository, error) {
114 name = strings.TrimSpace(name)
115 description = strings.TrimSpace(description)
116 if len(description) > 500 {
117 description = description[:500]
118 }
119
120 if err := ValidateRepoName(name); err != nil {
121 return nil, err
122 }
123
124 diskPath, err := s.DiskPath(owner.Username, name)
125 if err != nil {
126 return nil, err
127 }
128
129 cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
130 defer cancel()
131
132 var exists int
133 err = s.db.QueryRowContext(cctx, `
134 SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?)
135 `, owner.ID, name).Scan(&exists)
136 if err == nil {
137 return nil, ErrRepoAlreadyExists
138 } else if !errors.Is(err, sql.ErrNoRows) {
139 return nil, fmt.Errorf("check repo exists: %w", err)
140 }
141
142 defaultBranch := "main"
143 if err := git.InitBare(cctx, diskPath, defaultBranch); err != nil {
144 return nil, fmt.Errorf("init bare git repo: %w", err)
145 }
146
147 privInt := 0
148 if isPrivate {
149 privInt = 1
150 }
151 now := time.Now().UTC()
152
153 res, err := s.db.ExecContext(cctx, `
154 INSERT INTO repositories (owner_id, name, description, is_private, default_branch, created_at, updated_at)
155 VALUES (?, ?, ?, ?, ?, ?, ?)
156 `, owner.ID, name, description, privInt, defaultBranch, now, now)
157 if err != nil {
158 _ = os.RemoveAll(diskPath)
159 if strings.Contains(strings.ToLower(err.Error()), "unique") {
160 return nil, ErrRepoAlreadyExists
161 }
162 return nil, fmt.Errorf("insert repository: %w", err)
163 }
164
165 id, _ := res.LastInsertId()
166 return &Repository{
167 ID: id,
168 OwnerID: owner.ID,
169 OwnerUsername: owner.Username,
170 OwnerAvatarURL: owner.AvatarURL,
171 Name: name,
172 Description: description,
173 IsPrivate: isPrivate,
174 DefaultBranch: defaultBranch,
175 EnableIssues: true,
176 EnablePulls: true,
177 EnableReleases: true,
178 EnableActivity: true,
179 CreatedAt: now,
180 UpdatedAt: now,
181 }, nil
182 }
183
184 const repoSelectFields = `
185 SELECT r.id, r.owner_id, u.username, u.avatar_url, r.avatar_url, r.background_url, r.name, r.description, r.is_private, r.default_branch,
186 r.enable_issues, r.enable_pulls, r.enable_releases, r.enable_activity,
187 r.forked_from_repo_id, fu.username AS forked_from_owner, fr.name AS forked_from_name,
188 (SELECT COUNT(*) FROM repositories f WHERE f.forked_from_repo_id = r.id) AS fork_count,
189 (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.is_closed = 0) AS open_issues,
190 (SELECT COUNT(*) FROM pull_requests pr WHERE pr.repo_id = r.id AND pr.status = 'open') AS open_prs,
191 (SELECT COUNT(*) FROM releases rel WHERE rel.repo_id = r.id AND rel.is_draft = 0) AS release_count,
192 r.created_at, r.updated_at
193 FROM repositories r
194 JOIN users u ON u.id = r.owner_id
195 LEFT JOIN repositories fr ON fr.id = r.forked_from_repo_id
196 LEFT JOIN users fu ON fu.id = fr.owner_id
197 `
198
199 func (s *Service) GetByOwnerAndName(ctx context.Context, ownerUsername, repoName string) (*Repository, error) {
200 ownerUsername = strings.TrimSpace(ownerUsername)
201 repoName = strings.TrimSuffix(strings.TrimSpace(repoName), ".git")
202
203 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
204 defer cancel()
205
206 row := s.db.QueryRowContext(cctx, repoSelectFields+`
207 WHERE LOWER(u.username) = LOWER(?) AND LOWER(r.name) = LOWER(?)
208 `, ownerUsername, repoName)
209
210 return scanRepo(row)
211 }
212
213 // GetByID loads a repository by primary key.
214 func (s *Service) GetByID(ctx context.Context, id int64) (*Repository, error) {
215 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
216 defer cancel()
217
218 row := s.db.QueryRowContext(cctx, repoSelectFields+` WHERE r.id = ? `, id)
219 return scanRepo(row)
220 }
221 func (s *Service) ListByOwner(ctx context.Context, ownerID int64, includePrivate bool) ([]Repository, error) {
222 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
223 defer cancel()
224
225 query := repoSelectFields + ` WHERE r.owner_id = ?`
226 if !includePrivate {
227 query += ` AND r.is_private = 0`
228 }
229 query += ` ORDER BY r.updated_at DESC, r.created_at DESC`
230
231 rows, err := s.db.QueryContext(cctx, query, ownerID)
232 if err != nil {
233 return nil, fmt.Errorf("list repos by owner: %w", err)
234 }
235 defer rows.Close()
236 return scanRepos(rows)
237 }
238
239 func (s *Service) ListPublic(ctx context.Context, limit int) ([]Repository, error) {
240 if limit <= 0 || limit > 200 {
241 limit = 50
242 }
243 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
244 defer cancel()
245
246 rows, err := s.db.QueryContext(cctx, repoSelectFields+`
247 WHERE r.is_private = 0
248 ORDER BY r.updated_at DESC, r.created_at DESC
249 LIMIT ?
250 `, limit)
251 if err != nil {
252 return nil, fmt.Errorf("list public repos: %w", err)
253 }
254 defer rows.Close()
255 return scanRepos(rows)
256 }
257
258 func (s *Service) ListAccessible(ctx context.Context, viewer *user.User, limit int) ([]Repository, error) {
259 if viewer == nil {
260 return s.ListPublic(ctx, limit)
261 }
262 if limit <= 0 || limit > 200 {
263 limit = 100
264 }
265 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
266 defer cancel()
267
268 var (
269 rows *sql.Rows
270 err error
271 )
272 if viewer.IsAdmin {
273 rows, err = s.db.QueryContext(cctx, repoSelectFields+`
274 ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC
275 LIMIT ?
276 `, viewer.ID, limit)
277 } else {
278 rows, err = s.db.QueryContext(cctx, repoSelectFields+`
279 WHERE r.owner_id = ? OR r.is_private = 0
280 ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC
281 LIMIT ?
282 `, viewer.ID, viewer.ID, limit)
283 }
284 if err != nil {
285 return nil, fmt.Errorf("list accessible repos: %w", err)
286 }
287 defer rows.Close()
288 return scanRepos(rows)
289 }
290
291 func (s *Service) Update(ctx context.Context, r *Repository, description string, isPrivate bool, defaultBranch string, enableIssues, enablePulls, enableReleases, enableActivity bool) error {
292 description = strings.TrimSpace(description)
293 if len(description) > 500 {
294 description = description[:500]
295 }
296 if defaultBranch == "" {
297 defaultBranch = r.DefaultBranch
298 }
299 if err := git.ValidateRef(defaultBranch); err != nil {
300 return err
301 }
302
303 privInt := 0
304 if isPrivate {
305 privInt = 1
306 }
307 b2i := func(b bool) int {
308 if b {
309 return 1
310 }
311 return 0
312 }
313 now := time.Now().UTC()
314
315 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
316 defer cancel()
317
318 _, err := s.db.ExecContext(cctx, `
319 UPDATE repositories
320 SET description = ?, is_private = ?, default_branch = ?,
321 enable_issues = ?, enable_pulls = ?, enable_releases = ?, enable_activity = ?,
322 updated_at = ?
323 WHERE id = ?
324 `, description, privInt, defaultBranch, b2i(enableIssues), b2i(enablePulls), b2i(enableReleases), b2i(enableActivity), now, r.ID)
325 if err != nil {
326 return fmt.Errorf("update repository: %w", err)
327 }
328 r.Description = description
329 r.IsPrivate = isPrivate
330 r.DefaultBranch = defaultBranch
331 r.EnableIssues = enableIssues
332 r.EnablePulls = enablePulls
333 r.EnableReleases = enableReleases
334 r.EnableActivity = enableActivity
335 r.UpdatedAt = now
336 return nil
337 }
338
339 func (s *Service) UpdateAvatar(ctx context.Context, repoID int64, avatarURL string) error {
340 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
341 defer cancel()
342 _, err := s.db.ExecContext(cctx, `
343 UPDATE repositories SET avatar_url = ?, updated_at = ? WHERE id = ?
344 `, strings.TrimSpace(avatarURL), time.Now().UTC(), repoID)
345 if err != nil {
346 return fmt.Errorf("update repository avatar: %w", err)
347 }
348 return nil
349 }
350
351 func (s *Service) UpdateBackground(ctx context.Context, repoID int64, backgroundURL string) error {
352 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
353 defer cancel()
354 _, err := s.db.ExecContext(cctx, `
355 UPDATE repositories SET background_url = ?, updated_at = ? WHERE id = ?
356 `, strings.TrimSpace(backgroundURL), time.Now().UTC(), repoID)
357 if err != nil {
358 return fmt.Errorf("update repository background: %w", err)
359 }
360 return nil
361 }
362
363 func (s *Service) TouchUpdatedAt(ctx context.Context, repoID int64) {
364 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
365 defer cancel()
366 _, _ = s.db.ExecContext(cctx, `UPDATE repositories SET updated_at = ? WHERE id = ?`, time.Now().UTC(), repoID)
367 }
368
369 func (s *Service) Rename(ctx context.Context, r *Repository, newName string) error {
370 newName = strings.TrimSpace(newName)
371 if err := ValidateRepoName(newName); err != nil {
372 return err
373 }
374 if strings.EqualFold(r.Name, newName) && r.Name == newName {
375 return nil
376 }
377
378 oldPath, err := s.DiskPath(r.OwnerUsername, r.Name)
379 if err != nil {
380 return err
381 }
382 newPath, err := s.DiskPath(r.OwnerUsername, newName)
383 if err != nil {
384 return err
385 }
386
387 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
388 defer cancel()
389
390 if !strings.EqualFold(r.Name, newName) {
391 var exists int
392 err := s.db.QueryRowContext(cctx, `
393 SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?) AND id != ?
394 `, r.OwnerID, newName, r.ID).Scan(&exists)
395 if err == nil {
396 return ErrRepoAlreadyExists
397 }
398 }
399
400 if oldPath != newPath {
401 if err := os.Rename(oldPath, newPath); err != nil {
402 return fmt.Errorf("rename repository directory: %w", err)
403 }
404 }
405
406 now := time.Now().UTC()
407 _, err = s.db.ExecContext(cctx, `
408 UPDATE repositories SET name = ?, updated_at = ? WHERE id = ?
409 `, newName, now, r.ID)
410 if err != nil {
411 if oldPath != newPath {
412 _ = os.Rename(newPath, oldPath)
413 }
414 return fmt.Errorf("update repository name: %w", err)
415 }
416
417 r.Name = newName
418 r.UpdatedAt = now
419 return nil
420 }
421
422 func (s *Service) Delete(ctx context.Context, r *Repository) error {
423 diskPath, err := s.DiskPath(r.OwnerUsername, r.Name)
424 if err != nil {
425 return err
426 }
427
428 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
429 defer cancel()
430
431 if _, err := s.db.ExecContext(cctx, `DELETE FROM repositories WHERE id = ?`, r.ID); err != nil {
432 return fmt.Errorf("delete repository record: %w", err)
433 }
434 if err := os.RemoveAll(diskPath); err != nil {
435 return fmt.Errorf("remove repository directory: %w", err)
436 }
437 return nil
438 }
439
440 // DeleteAllByOwner removes every repository owned by the user, including bare
441 // Git data and uploaded release assets, then removes the owner's empty storage
442 // directory. It is used when an account is deleted.
443 func (s *Service) DeleteAllByOwner(ctx context.Context, owner *user.User) error {
444 if owner == nil {
445 return ErrRepoNotFound
446 }
447 repos, err := s.ListByOwner(ctx, owner.ID, true)
448 if err != nil {
449 return err
450 }
451
452 var errs []error
453 for i := range repos {
454 if err := s.Delete(ctx, &repos[i]); err != nil {
455 errs = append(errs, fmt.Errorf("delete %s/%s: %w", repos[i].OwnerUsername, repos[i].Name, err))
456 }
457 }
458
459 baseAbs, err := filepath.Abs(s.reposDir)
460 if err != nil {
461 return fmt.Errorf("resolve repos dir: %w", err)
462 }
463 ownerDir := filepath.Join(baseAbs, strings.ToLower(owner.Username))
464 if len(errs) == 0 {
465 if err := os.RemoveAll(ownerDir); err != nil {
466 errs = append(errs, fmt.Errorf("remove owner repository directory: %w", err))
467 }
468 }
469 return errors.Join(errs...)
470 }
471
472 func boolInt(b bool) int {
473 if b {
474 return 1
475 }
476 return 0
477 }
478
479 // FindForkByOwner returns the owner's fork of the given repository, if any.
480 func (s *Service) FindForkByOwner(ctx context.Context, srcRepoID, ownerID int64) (*Repository, error) {
481 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
482 defer cancel()
483
484 row := s.db.QueryRowContext(cctx, repoSelectFields+`
485 WHERE r.forked_from_repo_id = ? AND r.owner_id = ?
486 ORDER BY r.created_at ASC
487 LIMIT 1
488 `, srcRepoID, ownerID)
489 r, err := scanRepo(row)
490 if errors.Is(err, ErrRepoNotFound) {
491 return nil, nil
492 }
493 return r, err
494 }
495
496 // Fork copies a repository into the owner's namespace.
497 func (s *Service) Fork(ctx context.Context, src *Repository, owner *user.User, name string) (*Repository, error) {
498 name = strings.TrimSpace(name)
499 if name == "" {
500 name = src.Name
501 }
502 if err := ValidateRepoName(name); err != nil {
503 return nil, err
504 }
505
506 srcPath, err := s.DiskPath(src.OwnerUsername, src.Name)
507 if err != nil {
508 return nil, err
509 }
510 dstPath, err := s.DiskPath(owner.Username, name)
511 if err != nil {
512 return nil, err
513 }
514
515 cctx, cancel := context.WithTimeout(ctx, 120*time.Second)
516 defer cancel()
517
518 var exists int
519 err = s.db.QueryRowContext(cctx, `
520 SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?)
521 `, owner.ID, name).Scan(&exists)
522 if err == nil {
523 return nil, ErrRepoAlreadyExists
524 } else if !errors.Is(err, sql.ErrNoRows) {
525 return nil, fmt.Errorf("check fork name: %w", err)
526 }
527
528 if err := git.CloneBare(cctx, srcPath, dstPath); err != nil {
529 return nil, fmt.Errorf("copy repository: %w", err)
530 }
531
532 now := time.Now().UTC()
533 srcID := src.ID
534 res, err := s.db.ExecContext(cctx, `
535 INSERT INTO repositories (owner_id, name, description, is_private, default_branch,
536 enable_issues, enable_pulls, enable_releases, enable_activity, forked_from_repo_id, created_at, updated_at)
537 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
538 `, owner.ID, name, src.Description, boolInt(src.IsPrivate), src.DefaultBranch,
539 boolInt(src.EnableIssues), boolInt(src.EnablePulls), boolInt(src.EnableReleases), boolInt(src.EnableActivity),
540 srcID, now, now)
541 if err != nil {
542 _ = os.RemoveAll(dstPath)
543 if strings.Contains(strings.ToLower(err.Error()), "unique") {
544 return nil, ErrRepoAlreadyExists
545 }
546 return nil, fmt.Errorf("insert fork: %w", err)
547 }
548
549 id, _ := res.LastInsertId()
550 return &Repository{
551 ID: id,
552 OwnerID: owner.ID,
553 OwnerUsername: owner.Username,
554 OwnerAvatarURL: owner.AvatarURL,
555 Name: name,
556 Description: src.Description,
557 IsPrivate: src.IsPrivate,
558 DefaultBranch: src.DefaultBranch,
559 EnableIssues: src.EnableIssues,
560 EnablePulls: src.EnablePulls,
561 EnableReleases: src.EnableReleases,
562 EnableActivity: src.EnableActivity,
563 ForkedFromRepoID: &srcID,
564 ForkedFromOwner: src.OwnerUsername,
565 ForkedFromName: src.Name,
566 CreatedAt: now,
567 UpdatedAt: now,
568 }, nil
569 }
570
571 // ListForks returns repositories forked from the given repository.
572 func (s *Service) ListForks(ctx context.Context, srcRepoID int64, limit int) ([]Repository, error) {
573 if limit <= 0 || limit > 100 {
574 limit = 20
575 }
576 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
577 defer cancel()
578
579 rows, err := s.db.QueryContext(cctx, repoSelectFields+`
580 WHERE r.forked_from_repo_id = ?
581 ORDER BY r.updated_at DESC
582 LIMIT ?
583 `, srcRepoID, limit)
584 if err != nil {
585 return nil, fmt.Errorf("list forks: %w", err)
586 }
587 defer rows.Close()
588 return scanRepos(rows)
589 }
590
591 func scanRepo(row *sql.Row) (*Repository, error) {
592 var r Repository
593 var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int
594 var forkedFromID sql.NullInt64
595 var forkedFromOwner, forkedFromName sql.NullString
596 err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.BackgroundURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
597 &enableIssues, &enablePulls, &enableReleases, &enableActivity,
598 &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount,
599 &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount,
600 &r.CreatedAt, &r.UpdatedAt)
601 if err != nil {
602 if errors.Is(err, sql.ErrNoRows) {
603 return nil, ErrRepoNotFound
604 }
605 return nil, fmt.Errorf("scan repository: %w", err)
606 }
607 r.IsPrivate = isPriv == 1
608 r.EnableIssues = enableIssues == 1
609 r.EnablePulls = enablePulls == 1
610 r.EnableReleases = enableReleases == 1
611 r.EnableActivity = enableActivity == 1
612 if forkedFromID.Valid {
613 id := forkedFromID.Int64
614 r.ForkedFromRepoID = &id
615 }
616 r.ForkedFromOwner = forkedFromOwner.String
617 r.ForkedFromName = forkedFromName.String
618 return &r, nil
619 }
620
621 func scanRepos(rows *sql.Rows) ([]Repository, error) {
622 var list []Repository
623 for rows.Next() {
624 var r Repository
625 var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int
626 var forkedFromID sql.NullInt64
627 var forkedFromOwner, forkedFromName sql.NullString
628 if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.BackgroundURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
629 &enableIssues, &enablePulls, &enableReleases, &enableActivity,
630 &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount,
631 &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount,
632 &r.CreatedAt, &r.UpdatedAt); err != nil {
633 return nil, fmt.Errorf("scan repository row: %w", err)
634 }
635 r.IsPrivate = isPriv == 1
636 r.EnableIssues = enableIssues == 1
637 r.EnablePulls = enablePulls == 1
638 r.EnableReleases = enableReleases == 1
639 r.EnableActivity = enableActivity == 1
640 if forkedFromID.Valid {
641 id := forkedFromID.Int64
642 r.ForkedFromRepoID = &id
643 }
644 r.ForkedFromOwner = forkedFromOwner.String
645 r.ForkedFromName = forkedFromName.String
646 list = append(list, r)
647 }
648 return list, rows.Err()
649 }
650